From 0eb7d308615bae1ad4be1ca5112ac7b6b6cbfbaf Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 9 Dec 2013 11:41:05 +0000 Subject: [PATCH 0001/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] '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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] '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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] (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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] [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/1967] [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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] [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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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/1967] 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 b3382ffd4f455745c0401a0c2d6d74c2a182bc6f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 1 Apr 2015 14:59:21 -0400 Subject: [PATCH 0752/1967] 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 0753/1967] 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 0754/1967] 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 0755/1967] 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 0756/1967] 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 0757/1967] 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 0758/1967] 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 0759/1967] 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 0760/1967] 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 0761/1967] 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 ceff5cb9cabc7b5e7f9ae6adc8dbd48abe340b85 Mon Sep 17 00:00:00 2001 From: Aleksandr Vinokurov Date: Tue, 31 Mar 2015 20:21:04 +0000 Subject: [PATCH 0762/1967] 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 0763/1967] 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 0764/1967] 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 0765/1967] 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 0766/1967] 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 8b5015c10fa6f441a7ac5337ae9af8ca083fac95 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Mar 2015 10:40:23 -0700 Subject: [PATCH 0767/1967] 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 0768/1967] 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 2291fa2d45ad38d6804e988e001761f4d8a29650 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 21 Apr 2015 11:58:44 +0100 Subject: [PATCH 0769/1967] 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 0770/1967] 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 0771/1967] 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 0772/1967] 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 0773/1967] 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 0774/1967] 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 0775/1967] 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 0776/1967] 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 0777/1967] 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 0778/1967] 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 0779/1967] 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 0780/1967] 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 0781/1967] 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 0782/1967] 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 0783/1967] 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 0784/1967] 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 0785/1967] 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 0786/1967] 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 0787/1967] 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 0788/1967] 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 0789/1967] 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 0790/1967] 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 0791/1967] 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 0792/1967] 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 0793/1967] 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 0794/1967] 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 0795/1967] 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 0796/1967] 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 0797/1967] 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 0798/1967] 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 0799/1967] 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 0800/1967] 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 0801/1967] 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 0802/1967] 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 0803/1967] 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 0804/1967] 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 0805/1967] 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 0806/1967] 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 0807/1967] 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 0808/1967] 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 0809/1967] 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 0810/1967] 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 0811/1967] 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 0812/1967] 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 0813/1967] 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 0814/1967] 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 0815/1967] 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 0816/1967] 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 0817/1967] 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 0818/1967] 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 0819/1967] 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 0820/1967] 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 0821/1967] 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 0822/1967] 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 0823/1967] 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 0824/1967] 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 0825/1967] 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 0826/1967] 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 0827/1967] 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 0828/1967] 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 0829/1967] 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 0830/1967] 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 0831/1967] 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 0832/1967] 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 0833/1967] 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 0834/1967] 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 0835/1967] 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 0836/1967] 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 be92b79b4200f66e15d5913589b73de03efb6020 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 3 Jun 2015 13:25:26 -0700 Subject: [PATCH 0837/1967] 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 0838/1967] 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 0839/1967] 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 0840/1967] 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 0841/1967] 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 0842/1967] 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 0843/1967] 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 0844/1967] 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 0845/1967] 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 e3ba302627d9f7e63f70e46aecd79e5bd97e0282 Mon Sep 17 00:00:00 2001 From: Ed Morley Date: Wed, 10 Jun 2015 14:37:12 +0100 Subject: [PATCH 0846/1967] 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 0847/1967] 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 0848/1967] 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 0849/1967] 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 0850/1967] 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 0851/1967] 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 0852/1967] 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 0853/1967] 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 0854/1967] 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 0855/1967] 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 0856/1967] 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 0857/1967] 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 0858/1967] 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 0859/1967] 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 0860/1967] 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 c421d23c34fa974df79faeaaf7ca9c15226bfc27 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 15 Jun 2015 13:32:58 -0700 Subject: [PATCH 0861/1967] 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 0862/1967] 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 0863/1967] 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 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 0864/1967] 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 0865/1967] 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 0866/1967] 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 0867/1967] 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 5aa82a5519e5381a34f14dd51eadb924c4fba00e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 18 Jun 2015 11:56:43 -0700 Subject: [PATCH 0868/1967] 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 0869/1967] 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 0870/1967] 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 0871/1967] 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 0872/1967] 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 0873/1967] 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 0874/1967] 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 0875/1967] 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 16213dd49304b1d3bef228dda9ea4545cfdd87a5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 22 Jun 2015 07:58:08 -0700 Subject: [PATCH 0876/1967] 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 0877/1967] 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 0878/1967] 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 0879/1967] 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 0880/1967] 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 0881/1967] 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 0882/1967] 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 0883/1967] 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 0884/1967] 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 0940/1967] 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 0941/1967] 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 0942/1967] 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 0943/1967] 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 0944/1967] 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 0945/1967] 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 0946/1967] 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 0947/1967] 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 0948/1967] 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 0949/1967] 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 0950/1967] 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 0951/1967] 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 0952/1967] 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 0953/1967] 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 0954/1967] 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 0955/1967] 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 0956/1967] 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 0957/1967] 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 0958/1967] 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 0959/1967] 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 0960/1967] 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 0961/1967] 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 0962/1967] 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 0963/1967] 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 0964/1967] 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 0965/1967] 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 0966/1967] 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 0967/1967] 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 0968/1967] 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 0969/1967] 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 0970/1967] 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 0971/1967] 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 0972/1967] 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 0973/1967] 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 0974/1967] 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 0975/1967] 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 0976/1967] 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 0977/1967] 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 0978/1967] 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 92ef1f57022008d0bb5ed47971bccb83ed07afa4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 28 Jul 2015 16:48:38 +0100 Subject: [PATCH 0979/1967] 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 0980/1967] 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 0981/1967] 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 0982/1967] 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 0983/1967] 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 0984/1967] 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 0985/1967] 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 0986/1967] 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 0987/1967] 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 0988/1967] 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 0989/1967] 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 0990/1967] 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 0991/1967] 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 0992/1967] 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 0993/1967] 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 0994/1967] 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 0995/1967] 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 0996/1967] 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 0997/1967] 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 0998/1967] 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 0999/1967] 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 1000/1967] - 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 1001/1967] 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 dfa4bf4452584f1a2533254231b862d521d75f85 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 10 Aug 2015 16:00:45 +0100 Subject: [PATCH 1002/1967] 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 1003/1967] 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 1004/1967] 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 b4872de2135a41481f3cbfe97c75126b26c11929 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 10 Aug 2015 14:55:33 +0100 Subject: [PATCH 1005/1967] 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 1006/1967] 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 1007/1967] 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 1008/1967] 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 1009/1967] 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 f8efb54c80ed661538f06ecea7c1329925442b3a Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 11 Aug 2015 13:06:32 +0100 Subject: [PATCH 1010/1967] 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 1011/1967] 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 810bb702495f1d6d15008ed0cf87956b863a7ad7 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 11 Aug 2015 16:31:56 +0100 Subject: [PATCH 1012/1967] 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 1013/1967] 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 1014/1967] 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 1015/1967] 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 1016/1967] 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 1017/1967] 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 1018/1967] 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 1019/1967] 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 1020/1967] 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 1021/1967] 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 1022/1967] 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 1023/1967] 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 1024/1967] 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 1025/1967] 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 1026/1967] 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 1027/1967] 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 1028/1967] 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 1029/1967] 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 1030/1967] 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 1031/1967] 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 1032/1967] 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 1033/1967] 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 1034/1967] 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 1035/1967] 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 1036/1967] 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 1037/1967] 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 1038/1967] 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 1039/1967] 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 1040/1967] 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 1041/1967] 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 1042/1967] 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 1043/1967] 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 1044/1967] 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 1045/1967] 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 1046/1967] 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 1047/1967] 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 1048/1967] 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 1049/1967] 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 1050/1967] 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 1051/1967] 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 1052/1967] 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 1053/1967] 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 1054/1967] 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 1055/1967] 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 1056/1967] 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 1057/1967] 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 1058/1967] 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 1059/1967] 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 1060/1967] 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 1061/1967] 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 1062/1967] 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 1063/1967] 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 1064/1967] 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 1065/1967] 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 1066/1967] 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 1067/1967] 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 1068/1967] 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 1069/1967] 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 1070/1967] 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 1071/1967] 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 1072/1967] __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 1073/1967] 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 1074/1967] 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 1075/1967] 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 1076/1967] 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 1077/1967] 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 1078/1967] 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 1079/1967] 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 1080/1967] 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 1081/1967] 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 1082/1967] 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 1083/1967] 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 1084/1967] 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 1085/1967] 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 1086/1967] 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 1087/1967] 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 1088/1967] 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 1089/1967] 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 1090/1967] 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 1091/1967] 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 1092/1967] 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 1093/1967] 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 1094/1967] 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 1095/1967] 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 1096/1967] 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 1097/1967] 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 1098/1967] 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 1099/1967] 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 1100/1967] 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 1101/1967] 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 1102/1967] 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 1103/1967] 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 1104/1967] 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 1105/1967] 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 1106/1967] 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 1107/1967] 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 1108/1967] 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 1109/1967] 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 1110/1967] 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 1111/1967] 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 1112/1967] 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 1113/1967] 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 1114/1967] 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 1115/1967] 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 1116/1967] 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 1117/1967] 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 1118/1967] 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 1119/1967] 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 1120/1967] 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 1121/1967] 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 1122/1967] 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 4641d4052640ec541ac3fbedae5e2e5e86385b34 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 9 Sep 2015 15:54:51 -0400 Subject: [PATCH 1123/1967] 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 1124/1967] 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 1125/1967] 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 61415cd8bcf2d08dbfea957ca4801ed4f0f6e554 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 9 Sep 2015 14:38:46 -0400 Subject: [PATCH 1126/1967] 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 1127/1967] 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 1128/1967] 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 1129/1967] 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 1130/1967] 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 1131/1967] 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 0bdbb334476e6155cae346734a21f428ecc15a11 Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Mon, 14 Sep 2015 23:46:48 +0200 Subject: [PATCH 1132/1967] 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 1133/1967] 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 1134/1967] 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 1135/1967] 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 1136/1967] 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 1137/1967] 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 1138/1967] 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 1139/1967] 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 1140/1967] 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 1141/1967] 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 1142/1967] 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 1143/1967] 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 1144/1967] 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 1145/1967] 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 1146/1967] 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 1147/1967] 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 1148/1967] 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 1149/1967] 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 1150/1967] 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 1151/1967] 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 1152/1967] 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 1153/1967] 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 1154/1967] 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 1155/1967] 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 1156/1967] 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 1157/1967] 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 1158/1967] 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 1159/1967] 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 1160/1967] 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 1161/1967] 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 1162/1967] 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 1163/1967] 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 1164/1967] 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 1165/1967] 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 1166/1967] 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 1167/1967] 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 1168/1967] 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 1169/1967] 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 1170/1967] 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 1171/1967] 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 1172/1967] 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 1173/1967] 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 1174/1967] 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 1175/1967] 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 1176/1967] 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 1177/1967] 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 c37a0c38a2c4c9c49c5e591427d382c8a046635d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 12:24:56 -0400 Subject: [PATCH 1178/1967] 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 1179/1967] 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 1180/1967] 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 1181/1967] 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 1182/1967] 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 1183/1967] 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 1184/1967] 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 1185/1967] 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 1186/1967] 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 1187/1967] 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 1188/1967] 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 1189/1967] 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 1190/1967] 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 1191/1967] 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 1192/1967] 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 1193/1967] 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 1194/1967] 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 1195/1967] 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 1196/1967] 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 1197/1967] 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 1198/1967] 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 1199/1967] 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 1200/1967] 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 1201/1967] 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 1202/1967] 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 1203/1967] 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 1204/1967] 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 1205/1967] 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 1206/1967] 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 1207/1967] 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 1208/1967] 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 1209/1967] 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 1210/1967] 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 1211/1967] 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 1212/1967] 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 1213/1967] 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 1214/1967] 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 1215/1967] 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 1216/1967] 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 1217/1967] 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 1218/1967] 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 1219/1967] 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 1220/1967] 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 1221/1967] 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 1222/1967] 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 1223/1967] 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 1224/1967] 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 1225/1967] 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 1226/1967] 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 1227/1967] 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 1228/1967] 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 1229/1967] 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 1230/1967] 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 1231/1967] 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 1232/1967] 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 1233/1967] 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 1234/1967] 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 1235/1967] 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 1236/1967] 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 1237/1967] 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 1238/1967] 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 1239/1967] 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 338f2f4507919bda988be076f1654b4eb9dea497 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 14 Oct 2015 15:14:04 -0400 Subject: [PATCH 1240/1967] 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 1241/1967] 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 1242/1967] 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 1243/1967] 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 1244/1967] 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 1245/1967] 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 1246/1967] 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 1247/1967] 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 1248/1967] 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 26dc0b785b064a60d8438e386358a5c051a89907 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 16 Oct 2015 14:47:04 -0400 Subject: [PATCH 1249/1967] 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 43a89e1702b15a3322b084bb9bcc390dd62ada7d Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Sat, 17 Oct 2015 09:26:32 -0700 Subject: [PATCH 1250/1967] 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 1251/1967] 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 1252/1967] 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 1253/1967] 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 1254/1967] 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 1255/1967] 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 1256/1967] 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 1257/1967] 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 08add665e98044279ead90e093d40eb2161efb27 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 19 Oct 2015 15:15:24 +0100 Subject: [PATCH 1258/1967] 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 129e2f94826ad01b559c5b0e7f1ebc70ec7c97d8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 16 Oct 2015 12:54:31 -0400 Subject: [PATCH 1259/1967] 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 1260/1967] 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 1261/1967] 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 3f0e0835850462b750167f708d17793b45dc9ef6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 20 Oct 2015 11:39:06 -0400 Subject: [PATCH 1262/1967] 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 1263/1967] 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 1264/1967] 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 1265/1967] 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 1266/1967] 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 0fed5e686438aefd9968733fc3c480e7c1339568 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 13:05:14 -0400 Subject: [PATCH 1267/1967] 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 1268/1967] 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 1269/1967] 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 1270/1967] 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 1271/1967] 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 1272/1967] 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 1273/1967] 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 1274/1967] 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 1275/1967] 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 1276/1967] 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 1277/1967] 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 1278/1967] 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 379af594dab93e608d9589bb41c755429190a71d Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 28 Oct 2015 14:59:43 +0000 Subject: [PATCH 1279/1967] 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 1280/1967] 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 1281/1967] 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 1282/1967] 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 1283/1967] 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 1284/1967] 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 1285/1967] 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 1286/1967] 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 1287/1967] 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 1288/1967] 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 1289/1967] 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 1290/1967] 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 1291/1967] 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 1292/1967] 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 072e7687ae1e710ee8b82b44f5baba8f20caa4cb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 30 Oct 2015 14:15:47 +0100 Subject: [PATCH 1293/1967] 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 1294/1967] 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 1295/1967] 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 1b5b40761943bf9e358f7f70859d62097cdb4f1b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 30 Oct 2015 15:26:56 -0400 Subject: [PATCH 1296/1967] 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 1297/1967] 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 1298/1967] 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 1299/1967] 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 1300/1967] 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 1301/1967] 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 1302/1967] 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 1303/1967] 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 1304/1967] 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 1305/1967] 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 1306/1967] 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 1307/1967] 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 1308/1967] 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 1309/1967] 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 1310/1967] 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 1311/1967] 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 1312/1967] 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 e503e085ac98e6e744020f5068be40412f443f67 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 3 Nov 2015 11:52:44 -0500 Subject: [PATCH 1313/1967] 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 385b4280a1f6d2b36dccb43a0f99858693ff673f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 15:33:37 -0500 Subject: [PATCH 1314/1967] 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 1315/1967] 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 1316/1967] 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 1317/1967] 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 1318/1967] 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 1319/1967] 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 1320/1967] 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 1321/1967] 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 1322/1967] 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 1323/1967] 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 1324/1967] 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 1325/1967] 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 1326/1967] 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 1327/1967] 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 1328/1967] 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 1329/1967] 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 1330/1967] 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 1331/1967] 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 1332/1967] 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 1333/1967] 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 1334/1967] 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 1335/1967] 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 1336/1967] 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 1337/1967] 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 1338/1967] 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 1339/1967] 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 1340/1967] 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 1341/1967] 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 1342/1967] 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 1343/1967] 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 1344/1967] 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 1345/1967] 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 1346/1967] 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 1347/1967] 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 1348/1967] 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 ea4230e7a2f53a116c22dce20632cb5355cf4c07 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 10 Nov 2015 18:52:21 -0500 Subject: [PATCH 1349/1967] 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 1350/1967] 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 1351/1967] 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 1352/1967] 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 1353/1967] 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 1354/1967] 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 1355/1967] 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 1356/1967] 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 1357/1967] 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 1358/1967] 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 1359/1967] 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 1360/1967] 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 1361/1967] 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 1362/1967] 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 1363/1967] 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 1364/1967] 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 1365/1967] 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 1366/1967] 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 1367/1967] 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 1368/1967] 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 1369/1967] 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 1370/1967] 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 1371/1967] 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 1372/1967] 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 1373/1967] 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 1374/1967] 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 1375/1967] 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 1376/1967] 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 1377/1967] 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 1378/1967] 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 1379/1967] 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 1380/1967] 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 1381/1967] 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 1382/1967] 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 1383/1967] 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 1384/1967] 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 1385/1967] 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 1386/1967] 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 1387/1967] 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 1388/1967] 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 e760c42ae00b9e1cccf2aeff4a17a3f5a0bd8cbe Mon Sep 17 00:00:00 2001 From: jake-low Date: Wed, 2 Dec 2015 21:45:36 -0800 Subject: [PATCH 1389/1967] 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 7698da57ca9c8c17001a137dd128fca2881957ac Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 4 Dec 2015 16:50:50 +0100 Subject: [PATCH 1390/1967] 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 1391/1967] 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 1392/1967] 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 1393/1967] 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 1394/1967] 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 1395/1967] 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 1396/1967] 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 1397/1967] 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 1398/1967] 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 1399/1967] 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 1400/1967] 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 1401/1967] 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 1402/1967] 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 1403/1967] 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 1404/1967] 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 1405/1967] 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 1406/1967] 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 1407/1967] 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 1408/1967] 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 1409/1967] 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 1410/1967] 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 1411/1967] 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 1412/1967] 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 1413/1967] 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 1414/1967] 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 1415/1967] 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 1416/1967] 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 1417/1967] 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 1418/1967] 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 1419/1967] 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 1420/1967] 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 1421/1967] 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 1422/1967] 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 1423/1967] 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 1424/1967] 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 1425/1967] 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 1426/1967] 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 1427/1967] 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 1428/1967] 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 1429/1967] 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 1430/1967] 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 1431/1967] 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 1432/1967] 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 1433/1967] 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 1434/1967] 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 1435/1967] 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 1436/1967] 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 1437/1967] 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 1438/1967] 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 1439/1967] 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 1440/1967] 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 1441/1967] 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 1442/1967] 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 1443/1967] 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 1444/1967] 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 1445/1967] 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 1446/1967] 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 1447/1967] 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 1448/1967] 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 1449/1967] 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 1450/1967] 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 1451/1967] 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 1452/1967] 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 1453/1967] 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 1454/1967] 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 1455/1967] 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 1456/1967] 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 1457/1967] 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 1458/1967] 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 1459/1967] 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 1460/1967] 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 1461/1967] 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 1462/1967] 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 1463/1967] 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 1464/1967] 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 1465/1967] 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 1466/1967] 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 1467/1967] 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 1468/1967] 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 1469/1967] 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 1470/1967] 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 1471/1967] 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 1472/1967] 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 1473/1967] 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 1474/1967] 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 1475/1967] 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 1476/1967] 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 1477/1967] 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 1478/1967] 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 1479/1967] 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 1480/1967] 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 1481/1967] 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 1482/1967] 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 1483/1967] 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 1484/1967] 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 1485/1967] 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 1486/1967] 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 1487/1967] 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 1488/1967] 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 1489/1967] 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 1490/1967] 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 1491/1967] 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 1492/1967] 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 1493/1967] 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 1494/1967] 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 1495/1967] 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 1496/1967] 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 1497/1967] 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 1498/1967] 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 1499/1967] 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 1500/1967] 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 1501/1967] 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 1502/1967] 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 1503/1967] 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 1504/1967] 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 1505/1967] 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 1506/1967] 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 1507/1967] 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 1508/1967] 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 ab927b986fc5317a0ceb42de1546afe5326f942a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Jan 2016 19:04:01 -0500 Subject: [PATCH 1509/1967] 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 1510/1967] 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 1511/1967] 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 1512/1967] 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 53d56ea2456813c910a6500fbad5841286c94db1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 16:15:52 +0000 Subject: [PATCH 1513/1967] 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 1514/1967] 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 1515/1967] 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 1516/1967] 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 66dd9ae9a49bb4483601a161adf7f45e73fb5710 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 18 Jan 2016 15:55:30 -0500 Subject: [PATCH 1517/1967] 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 1518/1967] 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 1519/1967] 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 1520/1967] 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 1521/1967] 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 1522/1967] 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 1523/1967] 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 1524/1967] 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 1525/1967] 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 1526/1967] 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 1527/1967] =?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 1528/1967] 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 1529/1967] 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 1530/1967] 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 1531/1967] 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 1532/1967] 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 1533/1967] 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 1534/1967] 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 1535/1967] 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 1536/1967] 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 1537/1967] 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 1538/1967] 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 1539/1967] 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 1540/1967] 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 1541/1967] 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 1542/1967] 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 1543/1967] 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 1544/1967] 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 1545/1967] 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 1546/1967] 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 1547/1967] 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 1548/1967] 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 1549/1967] 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 1550/1967] 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 1551/1967] 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 1552/1967] 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 1553/1967] 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 1554/1967] 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 1555/1967] 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 1556/1967] 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 1557/1967] 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 1558/1967] 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 1559/1967] 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 1560/1967] 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 1561/1967] 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 1562/1967] 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 1563/1967] 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 1564/1967] 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 1565/1967] 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 b84da7c78bacb28b62ec0b1ad34edd3330320e5b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 23:22:21 +0000 Subject: [PATCH 1566/1967] 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 1567/1967] 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 1568/1967] 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 d3a1cea1709143feb0f2b490dadcf50090dbffc1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 02:34:59 +0000 Subject: [PATCH 1569/1967] 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 1570/1967] 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 1571/1967] 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 1572/1967] 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 1573/1967] 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 1574/1967] 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 1575/1967] 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 6928c24323af38fcce54078e51272be0951ca350 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 1 Feb 2016 16:30:39 -0500 Subject: [PATCH 1576/1967] 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 1577/1967] 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 1578/1967] 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 1579/1967] 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 1580/1967] 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 1581/1967] 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 1582/1967] 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 1583/1967] 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 1584/1967] 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 1585/1967] 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 1587/1967] 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 1588/1967] 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 1589/1967] 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 1590/1967] 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 1591/1967] 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 1592/1967] 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 1593/1967] 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 1594/1967] 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 1595/1967] 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 1596/1967] 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 1597/1967] 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 1598/1967] 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 f612bc98d9da19e5b1b75a8239dd138c9d146c27 Mon Sep 17 00:00:00 2001 From: Spencer Rinehart Date: Wed, 3 Feb 2016 14:34:36 -0600 Subject: [PATCH 1599/1967] 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 1600/1967] 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 a7c298799130a8eb07844bd4ecbb7b7dc461b7f7 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 4 Feb 2016 12:17:20 -0500 Subject: [PATCH 1601/1967] 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 1602/1967] 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 869e815213569cec8592c6d0529104489ba557f2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 4 Feb 2016 23:46:41 +0000 Subject: [PATCH 1603/1967] 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 1604/1967] 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 1605/1967] 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 1606/1967] 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 1607/1967] 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 1608/1967] 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 1609/1967] 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 1610/1967] 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 1611/1967] 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 1612/1967] 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 1613/1967] 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 1614/1967] 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 1615/1967] 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 1616/1967] 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 1617/1967] 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 1618/1967] 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 1619/1967] 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 1620/1967] 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 1621/1967] 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 1622/1967] 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 1623/1967] 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 1624/1967] 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 1625/1967] 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 1626/1967] 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 1627/1967] 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 1628/1967] 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 1629/1967] 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 1630/1967] 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 1631/1967] 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 1632/1967] 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 1633/1967] 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 1634/1967] 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 1635/1967] 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 1636/1967] 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 1637/1967] 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 1638/1967] 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 1639/1967] 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 1640/1967] 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 1641/1967] 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 1642/1967] 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 1643/1967] 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 1644/1967] 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 1645/1967] 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 1646/1967] 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 1647/1967] 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 1648/1967] 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 1649/1967] 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 1650/1967] 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 1651/1967] 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 1652/1967] 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 1653/1967] 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 1654/1967] 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 1655/1967] 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 1656/1967] 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 1657/1967] 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 1658/1967] 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 1659/1967] 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 1660/1967] 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 1661/1967] 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 1662/1967] 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 e6797e116648fb566305b39040d5fade83aacffc Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Mon, 22 Feb 2016 18:50:09 -0800 Subject: [PATCH 1663/1967] 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 zcmZ_!1yCGa)HMp@?hxEPxVyVMA$V{L?(S}b1$Pe`g1ZF<*Wk|JKDhtndEfij_uZ;n zHPbcK)2F-7-s|kW_FgAiO+^+Ji4X|_0s>WDPD%p;0t)=`Wk!JeIHP}^*9-x1Z6_}! zuH^-Jx-jf4C5s^?-Yzd%p&Y_LXSx;y|7ZPSzEjguMhN&xEdMyR&awN&lSV$7dIXS8 zEkE1CFB#C`dRKN(;-P7ndAydHbL5trv$3{5?xCV>$)m~pJ6j~>ctWexQd2nd%&p5O z&@fFnTOg$)moxYHfZKz%gN}3R-e2(b%^;;R#aFOA|9{U zXM+`oyX((6!=vN*s&nPsl#S7~(R0fV4o!Z4&V&6^kc061qQ|jEGhN_){2K88`n++0 zI%wC^RsztzJpbfypIu!(&RM^4YgRp9UOQ6KX=hkX+3?}!zb1C{nbWQm$rN6`Ke#5g zP!*l(x~Mb0?Kt3X;=R|_wz}{}^I`N9@K^C*&`im?U%U_yPRkvs9LZCj>RdS$NvvDB z$(=vFZdveds+`L_J7DKrZd+b4HvYY7oUf(&c23lm^nZO`ZGi8W1L#TA^2s+-=cB?|FT_YMRRT=&6(YJ6 zK6@ky?wNj1iFR7Si0Ou6eu{BBWR??C&09QQ^K!FF(Y-nxjgi!`Bo;6r5Y=i&54w&E1-eyslhavP325W0b@l8<;0*Cs`+M{ zbEUH#HAM7=GQfs1-~{}A`pyEH()wF?RYv42M>Q-TNR#l)Ko79sEBUb$96mBEBpGqOSpUnAnEeF4T z)-4%u^GDj(*`K|gWz#DdmdnE}{v*?#Lr`2w8;3iy$8d(v!dA$t9P8lACD3SMoroae zTQ4dQ;vb$-m%`A;HXexi&tS{GG6}`4t@yY4@K<176)S#F_=L^R+73wgzZ+4a3YdXk zsmgabPK%>9YXINU6+*66NS+#)@#SKN3K-$2V6P-Pr)uQ;)_zittIba}s?GCiPV+9a z{ry%7STrlrQ+0M6GA9Z5uL)1cW;e1Jl5-Yv5bYu$>v`u9@q2a`n)c0^NK?yrLTJgXSIa;pfNuh?m$F3f^gS z!uWmt6<50%;lTHI5gf-3HKtC|W00%Y`K@ElunUmPWzQu3>y7px*$mJ*sw8Tdv=l~g zkCHeVO+qIDTbKnKZtgSaw<&C->n@wRMPGlr5|iWS-kk{I?aP-exA7XK{#>iY-R-0~ zi9^jbO;Ads=(=2zvEATzJIW2eCzBwrvtJ#D?IRgwV@f zcEYnVgvLJ+y(Cycci%+O9?1r-MK%K8SKr{b?-RBck42#dC@zp5z5oNqQm$2B??W$d zt=LvKBd=w7ihZ{+{RBabJxJbpNe6&;TvV5)dgT{q(F>&)3UH7ft<(!kIDQ8=hk*jO zzaWUyj6IcvF6>3lh`bMvsSX$$LIK|Ox zk09N_gW_vYQ_RiIjg>( znq11iDl6fe_8tf>-nTas>2X) zB!qrMw6#GEexc9N=Ri70uM{@fbu(Y78Tyn?QUQ!B7Re(0vOOAY53JXZwlfkfpkE8*S`2F~jLy;k-m58M^)8msbopiX&dclA6JDqw83%k|@JJ%qLv{$O`;as?dz*z zH`=H@BqohY?alGZFW^V0+6)7#8@aT7ZR8~y*nDte$6CsW2n z-9B=Ur&WgXUV)*(;mejojId$8g)b!fD%HcLDsNdc2LqwjVnw`ip>3;$L}lcWaaz^TJm`VS9i;q*KTv zzrzTY4Ql+w8JI(Obap^ny6o%DHDVZuJ^^LGn=C=dxjC)qpf4*XrX zVGpbG+APL4a+zyXdPpZX{NrzAnF{fMBUWj zhIJbhIzhn&5AW>pwSkE+Qc;UdzewFd&$yCe1GWcCXalg5;4I&t4U3?%QjY&beDTZk zKCm{}gCEL!MGT}>aquMt?+OmM*ymeG5d4yvs#Ri9va^JRoxB8ExNPqi&gz8vu{j7M(I^Zr0NjKl^G*JK9q;ijoBaUtKcJQSaMP>!-Z z7V~YumX73Aj0(rop*s}cT5t+9=rB)McN&Ma_cIAGv`1vUj8$~FXBsksHl52H{Np>n zp@${#L?$9aKX&^ZB0hiiLGBB2c%ZEZw5AzSZZs|__duo*;DzBrhV~D3VUx2eWlRHI z>6H^3<^Ua1JKIEi{qrtF%0Sws2`b@smMwd!zGY!89txT)*>S7$%l__9$A_B_}PN=QlD9OLy>ji=UnkT9#XD4j0tZWVVe)o_(Fp78G&S`8id1l z12ynZcOfTo*LTphn(iTfH%iE~g_$D|!o;6f_jhg#6l;@2fAxQp(1Piav zo74BPD!5|lP2A}eLsO*2x~ToV!O(5*#(`WAvCVCib=7UF-|8#d5t7bc>+ifppMLzv z==w%-n&amzu%{PE6hBn%$3$}TU^?e9o#Z$LfiST{m6~d&7`o-h>lM4eiwM`U$#_j+}(c1T{<4S53pMm|p z>v`=)w}H8A_;6l~Pdf=UCwN;`CST?g_v$m3oagau78Tq5E>^Pami!WJnt1*0z6=`I z9gk-`1p@mH8m1uM3gp1%QmT4kzR?&+O9$@{wtHEcI#BBa-&+zB;(zbxY1E8oKK&U zL@QDZp#?e_Q<`Luz_v%|HH)SFNH}&k{^cbaV)q9~;Jer7%Ziu2ZsRRL9|ZlrlK4`{ zk%0Kye03r5K4Sw`L4%bx4Y1xA`OjCX2i;?;#a&q;CWXGxHBK)l8p5?BNak^Q|)+oQ4w;$9=6r>W-TJ8{je zcg8Dv;DSlpHH|XKhU9bI`{UUz_ZnCQ)9DFyeA|lJ{G%L#brvCAZGRh6B-HW#tmOWK8GoZV0U@!0Y~9tn^prTd34#?a?eL<R2z1gf^Rk3r0ABmF}LnpngDj}Xgyh+C_R8~cQsqyTfRjZf3m>V5B>KHBvunKrYO~s`1+$S2-#dX0SmMxN$*Pn-cYZigkqZ~x9@?ak-G9_l4?|t z!^Az_xp3*Zzy27)dK0n=e*(BBW6JYz=Xxxr!e2miY4GOtz-}JCKkARYyw2hyE^$22 zIi=pduSsM^h@b%HhNRy%x@7e=j-p@67>j>ug9QtfQ#$9$8tq(@LtcvQjNA-^I@wGD zGa?P$73~l}G@ps@*ayDlR#8oxcnEK}OXHp4?w(%iWn=H(U!<6Sn-QtWIQPjKW8IiZ9rN%)AhzPi$m z04|wHe^6MITcc+hY^v5`LD%I}H1_@7H$>KT73h}09vOe-?TU)E_5&z&yH|M63a6*> zB_A!! z820m9@*@DGK5P$>)Dv2j-|R6Uum7exF(ut(B8e zw@I^|c<@(2TBfcPT^7F26%W9vTfJI(e9CpamI4tnuYz|qJ z!@u)g$LzCaJksIFfs)M9>6MyNy+pwy#slh1Xq~udj`16+#(lNqL+W1kbT zfbqEqRb0{}q!4{9hXA!_X8*q>_}KBQgUbRN z3;4L&M!ZCWIjKGB>zHzYA*Gr0^d0R>GQIV|2TLHCZ4-*fIjVln*Wg!Z?zpM!4>N1a zh4Q{gH5H(mp3w|V3m~wzag=SF0oABgF>-W0#UprR&3#?JuZq2Etwv~?T5Wgvric6C z%MyOoxkopX=Stu9C(8GUA%sS2#ScDMazwgFYPVjVTr`We7{cQ|8@k%y`c#|*FQ>m* z9b+jRsK^z-WzS}9aHZl33q;1gBM9tA(4UuszcBFzSrFn(FXLEqP9sLC$2q!i@v5RP6A6yRG-uHp3kh(O>V2@(5EfxyQ8 zjZG9nMCMh^ZGlWwv@>%kDruI}p;-R0wmT z=$WPxk_oB@ixlovn_TsLWKFtOyM;MORX@jN@}tP_^tt}3a)jAw^Oske>B zY+H0PS?rDK2=sY2ARvXnP`{{C_Ob#IL{nq$W>}t=eFJRYN>_sFotrlUzdK4{d{jgZNSxXlEv&EZHE?|_vUN5$+f$bE0(kg0D%3}K4H7!-lebj~=U4M? zNSB*>>u)@TdPyn|W#$DC;inOvdA=8RKA0cGOno z7hwc23_&siD7G2SN0G5lN;8kzCGV*xq3rVfVoDr?UM+KGV&cV=d0ZA!Ln?67!G zTx4=m394D=L=e8_m*BX0;a8|*EWWY}i#fN3HsFXlHJidU&1XO*PJ%zBvr$)>pMTf) zxOT=fDhr>sEFZ{j2bKEec2XW59)q%Zc|bauY>L*kD#rSk(2Mx@?K4dKN;o4P5?fb) z0uIwOr(eJSETHl%Irl~kO+Q8nk?8#pwJOkLpqW)g!lo6=GiY^!1P@Q3VDMPdhkC%B z(>@`vn>q+(ympX)A_=L?GuRV>oCjR9&jiX5}YU$ZJYtmb_gzuE4iJRkA>vTX@se?TRHVwDv3B08!1mKU@U-}efe ztxF%b7Jed91=wvT&QytYnsE@(xSx^+pF3jw@*)m?k)0mFHV3UM&{)%bwYM7-PYIl5 zI7xNWRus4vB{CB$gY%wM_xwXAQ0hqvCm%EGZk<-gh}~7*0Q22YGH;jGAay|>4xqdP}MMs(yi|%k#sy7Nf`2~uanf;)bRxI)8S_B>#?Yi;yL1q-<{?n}mFn6tys#oq!B0-@@RxQdQU zU;}EI|NPGK_0rQ0qf2Y?cI*Ci8lmUl${GpzYB8T~Boi{>;`Nj35@o?(8J@~Im7p`? zuu{YoKxdUNi~>h#-9Y0IaV}#tEd%nY?cLi|OmfcXd+B>@_4-EIj~9QCjoFY`l9Dg% z_=5}lIo|Jj?KG>Po42`DP%Lk&@*4Az{Q?DC-kj|L2}*az0?89nZVt7QLV0`BrzR%f za{&`?e}o9ttab!{@C`_}E?Kd>1+{;q0}8Zf*y@xN&|vIHYI49fwI1{aS5;Jh9q{riC=yBQzP zEkRHoC!!dp*SqrOyMB>Pog6I*r+-~$Fm>BcZHJWJ-z%w$I>n8RjgoaX)&Z79n3{U3ciIA=erE@4T`gqHXItR7-S{hwu@0PdVYd@5(er^Ho06hX=Av zfir7Vy7*^K=JXsP!CfY+?}{)Ly|ilCaH?$gfjb!7e!Rdj z!Nl7DFId{{(&I43^C2x$qz$%4|5Pgz-7lt zsES2Layix#HymA}U|DPp1+2z-T!nY~t@j&%>dQEL$k7pv00e?EZ z@W_OKbYn#Nbj9%s)Mu|{5G^^FIwM3T9O@0_vp;(%pBjoG){I8Qs=1rv!L`Pqn^c2Bn(g7q1mc~+W-@#ODH zKgUM5zCE0=DS_$MF(0+N<-Inb8oAObc7VjW@9^zUAsm?#l#fEI-we8c{0Y3?nW+8O z^?rXbe3Sn(;P8|6g%jeDECZgPsu&zB`LsKWp8c`F*1gO{Op`z5X{R-%NDzI%P2yw> zx6{^UXl%cHW#%!$7VbLjbn`F$RGjP~A7g4sp6Or0B!?Cf5xCc_sVzgIeMHbm*GKU6 z3ZZe3UUrREHoCMl7hoBD{Dh6cQ4=|*I@;O1VP5|INeYSqiqHERGN|ZDC>_@i}HRC6EHXAx;&L42?_T%sG ztmtJK^_vBa&?IDMj(sH3W9w^Bur|I!0gIKj{lw;=PfIhTD&hIoQkM4D zU#6I7!)s*%i$!PkUVo67Hg>{&QBhy?+}~`Nj2jUcn|*dIO<3+&NR@ZBdTN@`4%VAz zy?&6y`B4=aMw*v0cQvI=v9js9cFqq=@O10%Nh7SarLH_vaOJM>Uo^|O$NOD@rEi9o zz%Np#o81H{viQ^Yb2@KPv5%i}e+FKs3cq)*@Mze5Z~CL_w9z1SV&CTLH@Oq={Nz;E z72Q>ZYqq@;AJta%oAxTkA{Iav>XpqJFjfS9#rzoqBx0X#Tn#So5fju) zs&~L-3FuKDWv!_Z64-o7F1oh%{_>H05>5_`c|>KzEAUhZ(ZWni_yYv+dW(*bTfb~< z4hB7bgYxM?YrHn9b{I-RrI!~9Ud7y`a^GzEC^?{Tj8Wg|$yMu_>o~0d1oUKWixj{3 z%>>)8x6HR+;eb;Y^Ck;1OpkHYvroCTiXuL~)DtZqD#N{B{->cN(oW8EPeWWJ3(66& z%CaT(eeHGkv@kSQiqys4>t&@rc}n+H^R)`==aZKUb)ULP;N7p?UZ0Q$ReVr5A~w0P z@2A+fNQ?&pG`i{3pqIbX_IhHKh&B8Ah+_QscFl#lbX8@+_xy`;>jwt;-wDV5iZykb z17;*Z3l-bHX<5ULJr_KsTO82{88GWt;%XDKxI*59M|h&2(oU$D>SQ>xYLB?yxLTr* z`Oz}t{vgZD-un!VId?RR*NRN!PEubwOIxSqa%2uA23zu+o$=V#ma711*y zMQ^2R#`!_?{*Gwk5_*qFA8ZyqRj{Exck_5Lj(?rXflr)Uf$-q4@9T;&CVO=>OdQVSZ(kq>w8hx_J;Ur?rF) z;c;Vb^6H_3Gv88J^(BX(;P+Wgz;T7IbjB z#+x8vQy=W(-wDTMl<%`N$VJ_|x%PM|fJF@wKHrM4| zzU}+?p4pU}Bsb#OsM5B1wRaCgy%uGkZI-#I*7qK5aQt<=b&+o43mEq}Zf(vRH_lGv z%;FiV_x5V@o95Qy4%gz&5R_!pji3yldpSrU!M@Ko{XDoy#_y}|!Le@;QVBeggHo>a zvf1QeXC9IfN4skwHZOpa)#@7MPq~33#n*NE0sCU_$A72fHX4G1{Q_Zh@hJg)g3w-X z9$)ugHrM#4iR3^$-Q)gRkwz2nRIG!z-r^mAdJCkT1yz~DMJfc4s6Lgj9|e-OQHe_Y z()0r{^C2#kzn8WRrejZgO!>D{B{G=dQkrmjhR>xuTJC{=7lv*x{ggAWAHOVKrpBz# z^610c-h0QM-d`l(L~a!+ej3Ei%3lMb#_wwhF<6R%EMNF1{qm`CkOP)k|lWEqv+cO`FS z`}i~`rkzU22{yv68rTo|W5Ugee@|s=yJ0}wi_Wus$XY7gQ}Jaogz#ofi>{%LK2!Ow z7?|GNz`DnYKr^~$&q1kJ4_?^^7}qcqvbY?xXMrj9n!1mIB3|CXhy$Uk-Uag|;%1LE z1*w5cq5$b|Lc12qJH&gRME2k#nUUXhI413OW=Vs}Db5Aqz?=V*RM<@V!=j`7NK*C3 z`A-R$budnkLV6ptRP8Gf#oKr;d9)0uA!y`f>BQgkDpUaF@o+t5>vZwqLrV4wN!4Xk z_f&xN8w!GAVq(0(rIW)@F{6Le5j2v`!n237u5KiS0{NnV?(bi*L@%RW9P57szZ@IA z$M#-XMrE0fn25x^>hrJQ?;Rl?=3q{^2CjoqqKS02=Et2;)@})^(M{PLfdzSOZE(|? zZh0UchL6g-CHO;CPmtJ8qLJI+hDOXTqjZ~;6Nyxv3q!>ISZHjJ;0?vU^}M~Nq9km8 z(+u|lXr)2U)J-`n*7OI-L{LWZ)VgpWZVGOf1202z);5`2#ANqip;9*xDZq#56OA-I z!_yx{1c$vRKrHY#|D4GRf#8dCQw3CurcehnrRW$>=4cWq;^rp#_D~*xN8VFcr>8f- z1s0<034gt9QL{RfeqPuQ0u)C(F$ zpAd7}6C;A*VJ^sNf2k&^n)&#*zIgZ6DXFb%KsJK@$kdWtd^*SS`#J|1zjwTEYY@Q?Kq8o7K2}uzP)i^Uxp~dLOc-*VC||0Un27&gacbjEpfZ&e z2Epn7R<=}7IL^Ehyb)4#4RiZ8{)%#2nDZ^zZ5URFGQatP=!2*yM8Ot|>e|Hr1K)?& z#PX;)+YOSXX+CswL*A?g;#FRi;;F1}KD^TUmK^@uF4YDiX;y9~%hru({tAYk3xw?p z-hjo^NVhwtE*gu~s|lvX4~n%67!9Os(X)>>h8wKOiX9yru^vFCcGQIrObJFHwL}SdENuU_5d{4zPvxJZ&kvbSj7xd8 z>X~}L%SAbn zV%}E+wDp=Mlgqf6Vs7f*1;czXqjb>z7u!~IlHuB=#M2$UT#Zt-4Ro6$n)~`yLn%;C zZ=Og`q;lP6e=uZcv61l->S3ta{!>(rWptE$Sw(kJ`28;fEBFVZ`{4c+3`8PaDvw8E z7<6tippzNewVs&ch)G_M5ltOpGmkhpuP*6nSBU!TG2$Reby-+4=%w88Kc9Z6JgI&VdC92^h z+1CKCt2i;3v?%BC%T{LG^ZN3^BE4>GMF(-Fmm+x4f>u-;mbmZPPp?c;%p{CrZ=C-R z^xjDRYjiQ0p(X+aIil|KMM(P;yTeyA3Z1xSBFCo1G4nhOTs(=b1E~%egu|KqM z=|lP^Mp+Ir6A3X@OdrM%v!H+;H_%k%G+{DO`e3Q>vm7BiT<0GNdDo&GWR7o>)l9vl98K~&XiN%cf-%&eo@ zT`bb7J{X<(6sKXr7dmsfLZJYi39{=!|HZbR@{h(EC3yJuseft(Mc3pF7=s|=x6Vf)a&n%nXFp;vBC?-~@UOgD*QUWrrgGpDsV{cd?*BA+U#62qM=ArE{BwRZyb zt)fL5`fu$Xqd(FWItJ-Yq z8-L@kkMX^9_}h9=8wCzS1(~E8I@A*;@gYAjGJ#iie5I1F!-oOV@23AMDvuX!#&wwT z`}WuG=XH^J^|FhUD<;#KL1;DT2$yq=##@rQG6m4^kH$@n`_ePR5uAu#o4zw;-O8xp zPQP_F%MV|kJRRyPUg0`@ZjUu#yx}&L&s=>%@arV&eS4m1#EkxBO|sR0GYAX{?i~L1 z!45w5U0snmj|^a@zpP{ab|gw5yc@9T=&I-rB)hO{N!^g!Xvc43fyZb$Cdzs9VG8xa zj+w5?xw-y%__6l}xJ>07F=DO4n%4^{V*AD?HBJy3RVPc~w@>et*tFw%_*qP7uPB0S zxL{08SG*Daohb>S(=X^jjTQBCC^S@YLzCFSf#4ONX&_FtXI^I^uSgtrFk%UBzyc{| z;`ryq=?S{shwWm%+|0;9HP18lMqydYd3m+XNJHn!hstU+A(l&+oK8xo-{ha@@H3iZ z3o2PHY(I0=P}330a#5}yU{h`v5X$n^z@AgX_66VO2lU8SZ>iEWEr%Qx%VR4#+vH@QLZh`?)p@@a1B)r9VE8pUBh!jIaLl) z3)iig(Mj&aCc8M&Eb~X(!>Kj%UC8U&qCYEx(}P~dBJwdXw@N?#@97a?SW>$L{SpQZxL>!M^WDh`V-OIGj$KVT zSP`58%%!1pN_ZDiXF7cnhOFw`Mht-@#KidYpDJ#jAS37f`Jc%-=F^9Kiwij~6^YQ% z;!!dxS(Ojl^FJx=K}kw(?<%9DplaIBYb26bRn)ja~SaL*_ zpkl(DNzkIAc=t62EFv&}q-u7}dOBkJmMGG~un2i?JAE;}x44-{-WcYPa5_tC4JPC{ z$C`xvVuc-t>`vBMaNP)%if>*|($_N{;eHI>_JO*l{ly^l*GN=6N{MoB5&)&qI-P1n z!)nNni7hzjrH@<0cm?=LszYHv@FWMm2N$4+wtk0%w~~+ZU~B$tQ!}z(py>J+${W69 z!v5oZ8O(c87ftL=uqi+<*dJ>Kz>ui zxv}in*Y_d_eX+_KQX(i>=o6sk?bv|B5w3(C%3}^Jd=5F-Z6X%;+m+1#Gu5TeIkt? zRxpO^FyQ$gZU!23*2z=jl+a+*$Fuwa7mIBrq(8F(C3{8`eHR#!{>gpTqXGh`U^F>a zJ29h1LFs%uz10n5P^Iyadkw0bRseR#CT8REF;Gsyojxir#ElK@$&dDZL@>0IvIuuvEZ22O%;<;A zE6+h`#t$Li?L**U|28e6g$f>Mgfz0@#@V19s<+AtSbgjAB?XnT6(fS($bXQNrQXV5 zC6&9t@bThrbqWGEqpxi)sL~s%Dqi_r-57xgtnSP|`jH8!Nw~TH>E{5pj zA5{-!nI5HE&gYPtI1F=v4Cqe6oj^!$#r*;NUUcman&NH+mQa>HhZS( zU_m1>liq+-N#x6Qv?f{Uu_p3yy4uuUw(*HPqqiFt@=c8&V(s=U7l5Vb5FvY>*03;+ zU-eJv{RS0lzQIB@g?e?yHBf6hs(=la>w@;&eB==$t=j>YpIl=}Lxz&~wRYplbTG_+ ze$^L;R#Uf7;5VnN17?O2gL13eqM$`Cem`081_}L5D^V84a?yS67Lb|d50T~XyFm-C z4e}#8BW&W8-R6@0S8xI@c>Y^A3mdk%ov6CdRF@RExVBapbzCiCLD8RypZp<*-@3He zruNwmb)di+s9my$y5<&TV%y$du^=sGKAMg_1rR@}ij99j^06ou`rXe&X97o*q*^Z! zy`=HnVr9AwB_#Y|U|_adLpxC8i6+@iW61Gu6p>^+S=fiX0+Ed$ zO){7%ai^*D#4{`Sat#}DH>Vu;$t#m1aa7*1(8A(}G9T>$xsajYGlrPtYsp`bn0!RX z<+qeIsV@yJK|&UKlukMpL;~p<(c@Bjj4{Ai-j0781&rogU0p?`@mOATaFQ-+?j}wz z4@u#`9bDf6R`GiJ7Fgf#o}b?GdiX{qdsx@tZo^LsM4U_lB-Oar1Nq#O4~m z{eTqWpZazkxFmRR0c_w7dPBT8xKb)UZ4du;+HAkt%*o2b;4$0QY ztX3V^(Mt;&Ahz`@nV?q7ANrq03j)gD zx$kErJNdTCfETo&W)1wklG7o41;_RnQ}+pE0ia9P$X}>UGpnw-(T1S(>Dyl`@?1WH zI)m1DLG4+BPd3AHGMWT8hgYQPL@ihR72P~P1fMqtb6*|iyQ(%(wKlv^y??2vZunta z@ERya3>OBX^!K2geH|K<8odZeO*it5<<6<@Ta^olRbg*PwlW@;o_t}cEr`~b>dvD zh@<{1g}d;e4y#b#I(FpAF7F9*mwXmSUi1ELBeC!}_DQ<>DTniBQ~;O%Ed||b!nKS# z91ZD-CuKL~A*grg&wS5)djMwTT?AF*BH6x{KGm~l4b8+x`uFs3p~QHUmavx|Zcs;d zk}6{WfU11*%CofQ1WI8gLmoa5D})>Y3tU7`(Npcn>S2H_{hM%Nea7X`^_;*NS(=n6 zZBbVJeR9lg_*dd4)iy);a>N)gmpAl!{`*(?m>P8S_+wSsF?OzvN|usAv*<9tOjE0= zMs**M=kRYE!4a1%gwHDejdY40p!YvHWxwvojRPKO@&leeM`o*B-XCynJ7sXb93g~| z%K>AH!u;5ypNxmJq5AGdm`a;)B0U~%~3*F-%a&QJ}f?nmc3wLk3uiCBv;9qee` zUodb9S!n{#-30h$|FoAfaU03pRs`gHw-wtxi+Q<-=v*9)ZLvSiQgqmDQX_<4sM*S# zu=A4ffB7(S(m@k8xb5aD?|Yr5gpni$j5r20NLpQ@yL20Y@Og)q{i6RJ!0UkFgceV? z$XYgSvlaa7)uu`?Y6!dPAjP6Ps=TFErntB(tvY%@*RngOzm1cMBJlZXkBP@nX7*Cw z+VQ*CZtl6nTaW%UpDV{W-She0Sp&vJ>cMZ%5hXAV(PK=CL{DsTP$V&;xA@270RTMT z1{aIPi!i^%gcrIgvTw$-9!7y%DgyiiRLFEEuIK1+(P2VUr(b2&dxtm?SE(rS1#Ms$ znRV1S0>4m$E)05-KkYNh5jZo}44-tMKDzvw+{iTJcQ493_hNB}r>Iljj9;|8y~fd* zLivB5D2@1-wn&k$=3!gBSD!yy%|PoI01liXJeZE4gW5 zBb7O0!ux!=HHQ0mmqYfhSlOQ-zD41h-tDf`!1?eC+?o~)iBOj}th-~E#9Vs*%)_7I zobPrryGvGN8dxWVO9!)4Md-Kl0K5--!ytOArvi*?440d-2_Ao2skr7Hj9$yR5me4f ztR6&#`FUDX81s4K5ZU`z7_ddEOgh&Qf{DI&jgT&jVu2lnoaneE9Zm1UOoaFX5L%3h zq_wO2NqA*lH=~HWt@|O)Nj#B_MV{#iCyWEdkF<>xUK|*TI>Y?V^9aBnpbsIZV2!~7 zpNzvTd=22Edf!>!^Lu+zw6-2r`w~Y`axo+;(S2vf)$OxOW7JWeC3(?3!>ulWIJA$Va7_duHR38(mpFG@u_N!T*V8SkyP)jI3*7 z27RNbxRl+6Qbo5qQllqZK^R1(h5@1cC)4&FjI$^hX6Ng2hfihpdqk+maIh=8vV?xIR7O%W~ zLf%^GTxZ*rtOCDaafdo=C}Hc`3Z2jY5<9^L7g;m~@Ru z?GM^~I1C|VdiTEo>$x=ky(N3un)}zvTDr83!Fb3RtGP}7n63>^L&aigwR)(?s!4&s z7Om(O#mpYLivJs5Y%t8NN5hV`j-WHot+NMmbW9|uARneDo3A{RFFoAr)+sD~8w0+~5*hLS~xPM4O z5N7!oJ49w|XpG#eCI)4y)brDgZ4!V;f3<&7lwOx)X#J6oS(N@+tIsQag)viJ2{_h4 zeUL-`DN`w92PYT%_r|kU2Ts0&fwEEuUS5Ye{z@HtFFvfpGVLdiiY7^^bZ73Lu4lCd zFaH0K`~TzXt;6DIn)XrL9fAaR_u%dX2pZfycyPDi?hrz7cXtiCxCM7ug4^N?EQdVL z`+dLn$aUtA>1$_uTI;Urs_Cw}X-g!KLH0h2FSx>B1+v;V|G3e8Y}V_`LSuPNACL84 zIjT!M^+-Hy3DkaZo?(ci&fOf*Ni)-w+vRM%h}})W zw8a&z>}fx6a*J3!e9xq;;c$(AL?f`WD%s=U=6-r+OV|36XffVkF&sM8`1vIx`QHo1+j8vfA>)6pM5mg<>jQ(&T5Fd<;AMG?n5+-?Lx}TY zmT#5S%%&|W{$5{|r<6U)qDl=4trAdy*t z=k#Y{GR0bL*3V>)$|o16IdtHitbLSk?*Pmj>^xdtHuszaX99b$RZ?nV{(KP|&vZ<^ zIP}GcQV#EmN7?s#JffZJws^D)1JCUWqbu(Vbz@}Sf z;Q3gt=%lwHV1Ng?)6#-HSGL==eh4n01&JP4@WFv%5VH<3o!a>U+H<*r1oO7nq`|^d zXX^qho}0%wKE|%Io7o=6tF7MyI7`>Jb$)`UUw2h~5{*GsQ5`idj7VA(wQk0EPDP9& zN)H7B?_!hkz6ANWDSzwYdw=2OY4*wOm$B6Ov5)!~T*^M!&!1^&R6WPU{ysKo*4 z6<7)KqdwF5ZXg}IVdy+wuj;>N7=JzdXi5rvtr9m1e)dm;k<__w-h4sl*$wAxmmwJxMM&-jbJb0X>HBOTEzuc4*9VGeX_0G(&%|*W zzS)Qe+~&D5kFp9jQcLu>2m88ZA8-gD-L8SStitc*Jy!G-D-U#LH+$(<*NpJ6M7QW? zr{-h4qoljC;~ksmR(WP!HW6e(K3uzV-@Z4cFRhJ^BiH@9G9Sk}UR`wxy0=U;@FP?rlMcryB8hkc9D^Y%Sz96R) zSXn<=D!2~Scx|PXp*;7~FcY=^MjnFS$;EalYL7OvYjYU_o@6Ml+w38H)zIWQosN9P zU=6iPoqQe4-Zd-ms?CNte2?n_yX$4PIXZ91|5-pgwA`x@HkQqP+VK0tUtYq)mgz*E z_S4m@tI$(Rm4!CITXC#xLVb9ALq~Y*(6%cq0e&g@>!Xj4kJ|lU~p-3F!=fKUg0RLsLAL#i4A+964n_GYMU~&PE8t#@z7~!DQ-*qiew3=e-RWh1wryIG#rJ;)m^oEZK?H6YCObVQ?Hz#2q;RvsM* zQDC{RGe+Fp?N(m(37;-K&xOBkWx$?(PNIv|F7c|wbiEx3C7%|tRPaPsP?{&e%faQn zS{)V%;wp))k9hl##O5Ba+}yLn-LoEavB!&|Cy*S^8R#c&?$D_sKFkV! zR@~so1$Sa=JcyJQrK(lP8rAt@=-McK?>A+C=O*>Xph`dm6*SaznwKSco2lT^<>UW+ zt-sMq(n(k)V-t6IBaQ*$pYqdB&;7BFIbck3;XpY_nulT>wAeT(>%<&(+oF(y9NDT+ z#zm+#^Tv^m9N#gxU7~&Y9*ieWxSaw6%JxENsP3PHU>qV^9Aa&RqZL5G;XnZokP}Wf zA{?k_M#Jcl(JF|V44le-|GK`Qr}c-+11>|cDr3)aK@lA{g}ym6j5!m zrDi>-2+qN7sS<0-)~I*!h^x5o>z)q1BdM{E9RFyX(xbZiVPj&gL1O|5fSOpM_VGni zLG#0+tg7(fOWz}?9qS1j0%;^-dvF=pN;pVEhH&}XI=~waS!10vvG+nkr)^|SyPzCd z`oH@B(uRaGdJ!!#B09AYnxJy56DaWrmjx(XkG(# zw!PwIRPhZ&n7VkQu<1nZyMhF0+-sJ!nOxpDS0Z|p7TVAB$V>|A%E+dN^?nF1MZDC8 zd;Bdk(RzJG@=Sd6n~fOq;+b;FL|rRx#3&TC(s8e>o!q&ql69~>9t^34_^iJ7M!=p& z$VzNSKm#t9QIa9n)m=kLBS?@rSi*T@+fRkA>4@+_h21uruA{fPrLbYk}{Y7wz zCY7JG{2f6`WphdD30^UWn~4>OI~TwT{|sughVpJ=?v{=D#`!QR%Bwc4cZqVnvcuO};biCx&Lz5mY{%8}L`=*QYgjt3o1gowu_I z`4(~w#26P-2p5O6lWGD&Vdm7u16Ger-iv>=elvkP<<)Pbi24P#$!<+TbD&ae zMGfHOiwO%Po<7gzU;{Y=+Evqku{cGAY#Ld8L^bY&0xdy-)-eZzhVH_T9cUxdC==z- z2st_!HM=ZeED4;>TjI0pu4Y(+HJ%oZ)J1ZgKr_%($Ss&O6~iX&C)2dVUg4g3~CHiOduozRBNt|1>5 z6U3kB!%tIWz_d5X4;!z$xKUx9chAao668O2M875g*$@pS?GyoIyi1?W$MnOiz=RD? z(B_eUxx@#R_IFRZ4el&WBg1#wn+D&7Xq7@T-L07b0X?y|#jpOb;zlInQJU}B&zzIy zk4mmovO?cKsvA+~c6Ow~{8BCGwPkwFQ%8J4CF64t1eW~x`n^JN>}DE$>rC_Vjdw5- zzpHMdF_srNqYkV~uc)-U-ST8#8ax%;hT`ZS$At2SB7d|_q(!8taz4z9>VZ;QW*mY7 z#8^Uq(XOhMmDSN6)MAP-O|A6$KD`p`C;W|`WG~)ekT*=Yk-3k6eZH^Y%Q=?t2G%gV z7hc1i@<1_9q(r>TC27x+wVaaUiuX>aCtI$u0J3X`UOx0>*vM9hX zRtDqsGgEednD0b0wK^%!=ip(TQZ!(gI>EJLw=7wDZ$69p-I_J4`O`4!0S)wOh@mZi z>`9A7r-)&kFxLyJ?DqA|1QY!vfE$^80R)f0lKD$6PWW`BqDeNmOC4Xb#URMwZ~ztX zd)^9Bf&Mb&r=?>qkqM5`58S@e1X=xr^gV82MfTsq+)^tciAPd@h;+})r+gGjPtc9J z5~J!(NYcI>(5IX>odn^krokygp&0!M71Is&6Jj3iix(-x*AKlqbBo-UnV_?0%GWLY zR9?rxq6!h-h@S{>Ny7SoOBts@irQfJc6ojBr!v)4MCUPvZbw-(%v@OiuzG1hZdFak2?TsrlP9I2K_u*u#h zIn57{S~}bo=6?3}4Q%fz#K7-n)fR#SI*SbvFQ;&uVr=POn7B^n+!3Ac>CI zfBYEyyrK79@-{!qNW6LgwG3hU)AuWpEp`Muz(r-gctLL6jaT;7-L?~dVFE!2<>>eK z?H9+E+VCKS6(lHBhE$96u=hq7h-k{ClO06LD|aawjFk4bZCI*5n#AfBc()^iP0PPR z(qZ|wm2k&AwDK1-&mNK~^1VT$3f!J?86zZ5KWLaq}K6;PFY`-dIF>U*kyI}LBG!YsVIQ5RDXge?~XpjnHq=}D!7eYP9nmDEDDw=Q@ScWuP^q`?2E3>V*(s2P9-9U+V^8tZ=mt*U~olw9oDfwT;Qz29;NHS#nDzC>j)v;h{;Arb!ZaU(n zo9E;9v6tl}XJAu7&vTU9z9*I74;ZLC0Ip;bBGmt;c;1o>Z{8xI{vT=3KZQa;ZL_EX zyOjl;0ZDH85y>C_X5Ai;PP!Nqa5>m(S$~m~uc>+N3jF-pWANHqcU`f;%1>u=b%y`z zDeu_Q`)^z?Uv|2d_TFUySLhwQUEN#GeGTusYBae`-4%Wqs7yzA4g!zWkewM@sVVWi zp`gT*E|lnQ1AltVmqm6#I;vkqGqaT+tD>JnvsfM%lt$P=pFmkL-?+9!U#ZAfajuq3 z6o+qP?|*n@&<1BQ(FQ6>2Y62Q35wl|e;$9@p*2M|ct`Y6CPx(r4Fx5$nXJ=$KyXxs z4@5>>oqh>$O!4bAMUvTM2E2*ZfTY@*Uo4;u*3rdbFFG-Yqty^vmG~+&#qHxm023&b z3KI&70i+gq-`P7ki}TW-uGPzNV0k>=(o@pTNb{6^uGemKV=JFykXx+}>~`r?^51*0 z>jmjZt~Wl^!aA|U9Zx~5$z!AJ@V(nDax`B2SudzN0Jzb@f2Sgs!E0`7g;H@OtVzkK zCf&VNS2iwcbXL04B1O0TYB$gCsk=$7CQxX)TpRepNu=m8C{)CnnNp3IrQDcqqV?B?& zqn{(uWXPbP>Zp6ij}mzxj?e17B-$h9wuL;}?=ipU^Z-sCzj5kaKZ8#*$z8g~=A4S! z_e{ro*=Uq|wS8-yLeXHRJW0pR*|p7;}QWA+W)UV^3kr@>F&wMW@-3#syQp(MRkww$K)%W>teLc(vfb4`dmwQyo_72cB zL``i4dph?;WL@@APfE3foK=+tSq4%F^OxMSM~mPfgsEAwGQiuVvy|7xQ@1f|U0@cF zj{x4X=hNFjJth$T9VvaBllvR~CfW*_fd1gSMmErv6Vli>uhv&RcK8%kpcvEN#*@2? zh@3mQHUWC;iWk_ACta4@)2pI9AD7x19^N)u)HB(g0uojdlL;>BZdUs=TTb1WhTgyl zEM;wXw(^FMxe4VM=8zUyAV!e!(y~&LS(F{GU5}@o&wPW9~XTk53vfy=BC^hfH7AY;Pnqx<97mTAC>MBy^;(4+GF z5QHIte6ORmwI%c5`5tYZgs3Uz0$eLIM0z8TKHO=$M5;@4C#KTbv=4Hz_f>i=LrND` z_@~oon5TdX7eR~3wnVk?>4rCmdV6Gizppad8#}eONB$sPwB2&#*d17%3ASAKnl_%E zAfy(w8(y(Ws*Oili1r(NrXC_$_=iVxC~;6dQrHXaV2KJn$K z{PqVdJc}r5k=iAln;Bt{x)hDx(lpT)EUB!drKq>ljI@lzC1v}_^ni7H9u1`XS!KG7 zFQv6Wmj9swuQwDkGc?oq79RuR{@i79vL3$*QNO+*vY7kr2R)u~nAI*(ImT}Pj?uPH zE_EdLt36&dSUC~fuDBxib62?*m~g6|t&>vuJAH&0GLTaZrVJ+MliX|L`x{RM_kdDu zt!_)=&YOd$05I7PSKYu`H-fk)?C9rGg1?9n6RQ5`SuSU$C+Ne_kN2oT|5y0Emd$Of zGF0+ee*g5wgZ@6D{};(JIaq%s1=N41&zIMjwKSPuK10Sgz2%yd>}}ScJnr{@x=so3 zTdPd%{dDaUIQWS>wRi9Vx-QD9?iX@s3gI_S25ef>5E`Y_5V#zPIRv*1CYy`Q5$W<+ZU%s~QP^Yy*yL@I$OixE(tRS8_45L@0 zsU@Z=eL?DQJL*5HDo2*5!HE*jiH#M{`5o+I;*Hr7dlU^tXSbD$xnGkInU{rA&4e?L2o{c#roslKBw+(^y$l6-vn4Vb{QU=e;bt5&aBZu# zIPkvb{i^;9sfY9a>6$T*tQCWdqJ)3xb|*?M21t3UH&~d6C|J0hK~xFqF3x!=Z|+F4 zZqc%A`!JqyW?vsyG6B#z!SQgCiwmvNP6AzjW5ovi^QS*9OZHOyU;%Mhnpf5!b^E7B zxTp{r@Z;ZZZ0f@Daeq?PTUZgRb0bG_x0?BJvg++Pq3W2CODxxn&c!|62ayk~5W4Qd z0i?ir$@XDdu<)YA?%H9X>)3sPzhF6}c7Ik6Gl(j@Y!>+G&#&+q1) zfN#ktQI{&>E$lg(~ew|M6fM!WBySF1@PZ54U^~sJ*%zBXjS} zVZ3poZlVw#SGjPNj0=M6n)L`*Ko>v1!j_?2z-@)U;QL+d3-hOIEVSp(=wfz;?@KN1 z$rg8<@$xNzsUT@9T|V@^i{&UL7GBuPGJyajpit`=PSNZ`v(391I`5Mm;BHQX4ZlW$ zA#B2TeClP_k>;`eIbOm@1$8?VYU5$>Jhdx@kYo7F)VBG`j9%+!C!|}QwYI!INZP*p z%J%m1MYE%dJUqCMkd(unac%}kT(RF}Ah4>&BT8RCNw5`uV(kfQbni<{MySGgH~RaO zAyj6gr{p(1%)6%Lg9IY}N4#4|KRLPsBZVmgkG{wmh!Hork(ddn;P zWJ@M(D~JyyUD&_$FyLHo5fVZJVom}gmZ+7^W^+L6+v^7*zr;$a2ua<%q{ih?O zCM1x<1Zv=MqE@nkIj26Y>xr>61@MHdEwnw7KXdG!(L^X-_$N8ZIJJ1d7(}NnUrXZK z7~$t!KFn@7olSp%Tw~sv)P?S=i(2xfG4=_cix8&3mx9!bcbyRJq z)c(p^@YrkpFh$hifdNCGuiV+skbOhCFA@$FQUEL0I{uQk<2aKe zH-x;K%P)Fe*7I3DIeLX?pteZct9p5yq`+HgZ0XYh-ksbGW*x&Su{=wqgXEj8*y2n6 zDoZS0TJn?D)W-H?;c?dev!3oD6E&7~g_a80VHYY#UB#yj2oj1O+9saPzdmw>vH zaGyZJ08V({#WGPYve!z&B;r==sl@EY+g(8*udX{$oa{xh!xRs+0)L4=jN=9AuJKuZ zefA82d3>>X=Wx=vf`A%usmYR?qDsc$FhcEsIvrg)Swn;K5ij_8l8K(*mI+&${rfQu zwr8lCwTNuZ%RbUr3{?yzcjF-D#(Rv!rGn;V(aMV|w!N>NCk=#GPHw9Y-MwpuXr2|! zk92ze3nH5b&2Xd_d!NIx8(f@%vqS7DNyMx}_0tvPZ!?~rex*`ct>MJw-AVbjtu8rv zr(gJJD(YsolbCv&6(Q&I`8h!Y@mBf_?EeJ3cz7~S{n{B4)q(8TiWqd+hBH~i{uImG zF&9a5j@fE7#jeB^23Y3~|9+pJP-TF7sy|ix62qjG3oD#^QuLX6?dt>qM`wXT-m<|c zz6_+5Ijv#(SmBSu(18?%3W@~EGCW`g}il=^*-%42k=I6Y! zz~XJ~YIfNmcb&=S*T$4_4$PmyrK3Bk(2pn>KXS7wN9OgueUvKiwTqaml03a0*z*x8 z#F;nHS(|VQ1x6v%nG4-P2^@LUt&N+Gnp7pYUc%ywjCl9o4Yk{$0uoa1t~xhpC8tDA zU{89BlCp6R429MJ6JUazg$4`mMGY@eIM5l>Skj0as#HpAw>TN=(AYN`TwhndAjKUsPshVhN)e04j(E>Ay6E!i;7xmI9KSUUOjg&Yt1fL^maaX#y z{j-i!@tzxRyHWk0ZqUqC1NZxfrxL{>2W$Cb`!J>RjD*L?FpqSo@gG}in|S;O@4lsu zn}!#eS8^t^L7!sfffq>YRkaTKx*#LiNw51qDrdG`Vx}8^#W|6ay-Nx_kb&{Wi;^xN z@jf8c5n?0PVAcCYUee1##PUtp(PN0$SprJaTjm+O@F?2XLGc#C0WJn}oO`FA7>=xE z-uZ|=TbM^}sgRe|e&lWZ{9XxJcYSw>RZ)OsW!vsmKKv6laOHLZiM2NHAsIP3dSb%* z{?yZGcqgJJ-~@V?}2iS@$Ff>phV)9RVcfty`=M@#;)a1@yfv3YW5MaFb|AO~8s7!dZF=i`RI3-NZRB`SO@U81! zf-Xi-aZV0{{@^kJEi)FOEu943yaOZEUU*~nWKHLFe%dhtCIw#1q)2tMha!QT6~!yS zZ;h{0`2@K!VbCSl`)1WtDsd#P09rZE+wy>jL;qoD=cjz&8 zxgP2X?3!?r`osZ(Mp~+bQ6$)tC<${*N=#>}_uWOt?-6V5Aot!Lc|^n(@LWhbZ3b$& zD9Ut%cu%$rXjfY-O(_z%DGRVJdH$83h4?g@<9d!(7ASb5 zVF$BZzm6ExFB9`{#jMp*JMH=I;N1yxNS8}4RKHpZ3VBQa9+M1pBK_Yr*Dt-74KRM= zc-qcZc9(LueTCAl2fFN0$We^$cEb~BN%@BJOJ!w^_n?T!4eW{8R`5iPfy(UHfr>#) z-@5ycISViNfuX+daxjDaMj}H?i$Ya?MOIXy69!5P2M>YC?1|Zf@ zu0OqSqRj+kfzI=md6B{a;tImd#>>A~Pd_{j$3otY9#q1zk$XuH0n>V2qLa>0!{2{l z#b4OGnP?qMFou8VKdyKn;W$#>{{XZE~?hbtvpcIJL=>|`r(Y!4`M%|9sh{{ z-$y$RUtVWU@u%zCLIoTe^juNcs*9(^!PW3eSWKIEp_8=EMz5npmrm6`$JAE;DL#kd zyt1-#&xY2~vk$D3vEXXjIkr!FMF%tQQB)U`6bQGmJv4Ie25R5FE}_W?#AWQPZGIrB zd%S@fJ8?4J$qbz-Rh0t?r{&OcV11?p3G=3NCFLOft9GTxe~$;r>u0K>ylFquKHsD%RAgl-RH(c+ z(7*CPu#`vyRG}U?V-m#kC*l9e;Ue>&9Hx#!fA0S3U`OV(Fy35T|18s}{!X)n1ssTG z?kqM23pWGd){#B5U@o>1E7u6=6`qtexwRe*M};o`I@9NM{N+usqnDHuWkAN#3sc{6^J-!*HzvL||4*yF=xiAD_6MhKtnML6FY30-?7r4p!85TzX;ONQ)}*pOH*_E$x6I6YWC0 zKj-vC@?^bm<}e~Wi0JyGr?XOu2y2INa+M|fagO1~#3)jb|G=Wu;KwZltt?yO*R_Vo z&ofrJI}UexevBq?8@hy(Cr;5e<+W-9qahy^C=y=SR*7Mq9DWSF27X?nxXO}wQ7Qj$ zIAH8M1@*=gt~(NJPy23q7qqeXW1hb{X|P((usr;F^YC%TynNs3?6+5HmK!|m;^-ju zlR29%`%GnT zGr4i-b9o>?Ta$eSdXEe&aSAV@^2Eht&5Az0f&7MGFU`!i_&3}k0Y~2yV`ShyIwel9 zfu+M8EY}v=IMrRUhtXPW(&x|tGS?JnsC*VxW#RNjkIj8z2DNAk^3rn2-pfxg;5d`% z(9511KLPN=M2}M7EAiLU`c;H=RUs2pW`f&ZE$5K8P849xjgSCfQ!7K`hF;Nx6I*J; z5Ob@0URZ2CN~sEWPs`b9t#2?EzFlQ0@W4b^cDfTR&oG2p%M zB6$oGDm@5oGZ#_m*dNwUZPyC(=lgX;e3>H9m-}s41gMOh#}N8Ph6_&Ba}7M%+In*5 zTo~E8;_!RGQ-QGcuO|{FK<=+5Rt%8vnDNft?gO0+k)Z7s?EjjJG~)eMh{yrG&gX&_ zl?!=)N?^L*qGU)@mAMlWVGs!joRT>n*`k+d0Y?36(Qxj}YWPV;Yiy|jgAo*7z{)bQ7VkXKRnkAjgA4`^%*MIw%g^}UD zkYJ1W7X73qDjdg-N2l6KUZgVlDVe$dB){myq_R91<&$tPfq&vw+oOEq$W2a$mSj%x zds`O49NvM|$r_vCF8jb$6h-PJV5#|L8$;Dd!Gk}(tZVMpJ;OZ_RviHt^pZ=?OHhJ2 zlu9F>cy}<{=+UTzU)G{W$^HUEXuBTlua?u8_ow`A3q?cFA!HrvJD>UYzzHOfSHsLi z^DL$ROUD0F*3o#)+#da`Rt~#1xPo5DNYzDU`}4bXx2U^0Nw_xg%W>X$s+;^R>o(&6 zsHw1G_Pah_B6%YoY6BQYm!VHBNcR)kp~`ArKMTLt)~uH@T==))wG;LC_nQo#im2-4 zTgK3jN)Fo$zg2Mws|4y{n9c+VBTw^(+QHo~y6Pnd;QVQc^}QCPMyWSsZ=2q!hNVZz&e_dJwtr6_!}I^%B-;?FI&H!8+b_`tM%}TZp#W3YD%t1LC}OzdW5~Nizt*_k9*H?iWlER^qZ7xn1Tk;;4oUho;;&LxDPZFaq4w< zLUgi?+GfAYtf|mt!!EQlq`wb!B6_R+uRXO&Bk0XM+ELhzbaF5q`fFNjq<5;rDv;^7 z$W8x6v6n+xMq`?`YnmiHJ}fKw2p>sAbsy z4j}gyjqWY;|Lv83GynH1|Ni~|mFY1h?Qdke|1Rs^iX5rcSQ6yP!TjzIdcizr`ZwOcq=W#uh|(+ToGAu=2JWSd_l``rZ^OY zUz4dn(r?mG%p8gr22sU9&(L#Ie+cIdDhyKCWAGyVVtu%4c=RG{7=Sn>IElBQNRqj- zrRU39RwL*xsd;~Kx@x`jWa{;LxrdoU2{0~Q{)l^6nqpSQLpUeqC`5v=6epv7^a#lN z#-lNC@O)$up+#S5j=GdJLUwsO^X2gO6#NdTat(a=IND*jw1K?qYJ*D}arCt9oh}g^ zBvDu``icym{2|RT&pu z@p4QSz)xR3+%9*q5Ai23*6rUl5CT`034yx#ayLht_dVK)y=tD~eJwXouL1EP$1nbR z&o4Gh%FyXM{?ZRqrMILXaQtwB#ed5%jO`XOJxhpmy)(dRVFI$+Gbzgf^TVlxo zTQy?x?CjaJ7u^pP)F2p`oh@iqTgGM;4;cgFR6xoBS4 z%xRf#vV62}pB_tv@e{3rth#+CsjqLMC3aBnCGd2JER53~vB(Yk=6NKqRq-r}+rrrR z{Txnm^Vz1%# z;x=Z<0A-*BCht81RxYlXrlfQMw=&taL^aPuj4C#^o@-6Bz_V2D&!5kOE%U+0iem|E zQ<^{0B#D!g2c85pT&N(Cfx6i$Q=t|L%gM?kuS?XFmg}d46*pkkC7ejPCo$bNl`u-K z!|2VxA zb<#8}s-b`N6m6D6u<6hZjNFZYI##Uz=+-qVR`8K6J~--$z|NrOj`TB_M_`okmAu!C zZv#^|!8ygQkqbG}buhd(BT)Bq(1|hf!MB}{iC#Xm=LGc*yXuGn=2lsjaaj`9LWoG< zVQq@m5rJ&8Zj9BC3u^r{!yTn$jPO86@uC*10pbCq%8tT}oPA0#q||xW7w0MFL~c>@ zX1blyq2rYI>8aPC;(9>4$Z6!>uBfH{U(%M7Km#G9Is72oysK}uULGoM6{b5?iDyq# zy>WLfCc5Kv;%h;#JShez@F|f5I>{TtN75>1e|ePB$!gYPwqv+K?yLJ&ns9uPlDbNc zjV|__k-J!0d#lmzKbK4hee*zW%6+$5JUSx+^6^SMGTg{fe&X`jel3HB$>cmPi&c|ZeWoP)MTxY!g+N<};C+&i7-P1`qHx!GpSR`fdey0=hKy9%o&$x-} z_Ut`CWkn~pK^F~VZ;u8lq`w0!S+j*oHz84!~fTNQft1>R_Jipu*GOAz`uHwtm5Oxm|AwFw>1_3&#`$Xz@=_Cr2YvSgmy9*J#4REhD zqK}lF649u|3C6Zk&6n4S)#-4|^gj`cPon8R>~$~#kSu(=j&mh@rM}VklYoXiVzXba zs6zC*-QDsh6C5sincqAS1-kq!O^<(9@&oFpnyj^*UHM&7LQLn1YUM z?yIXB8`KX!roHdtGCU(uz0(*l@B z&8O{k6HY*5JtrU`nGnd6KuWeH1ZOX&_p_WxIExzo5yvWJx=qQBEeeP=f zw}P$B)u?FO`e~RakxD0vgb}H=y|(b69z#^XMOlZMrE)5S`r_NINOa5b7URI?E7EkZ zpr3nckBCH+$ddSGL3h&Rdm!gWf~Srykw5PB&hNV(6?|`4^gvz~4bks`d>NZX2Xy!$ z!o%3FLc3|?lYHw-9};uV*FQP7W*M+$G_4mf2}`!XXruFEy@#&t5!qf4 z8`<;Qah?yn**EvNqGmfbLkHPGtNuupyNNt_SAT>=AVHph57@gtuDVV7W$j31c#4r^ zX!;U_k>Jz4b4EH0)%JH#S{Vq89Etbf%VY>uV?$b;REB?G-PC*Z>R5iNnD)?Fe5XQN z0RI@7f`$v6&BZshXd`14H*%;%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 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

O2I>c(i=A{R+)A=E;y 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 literal 0 HcmV?d00001 diff --git a/docs/rails.md b/docs/rails.md index 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 97bbee19b7f16055c42d819bf005853159740b19 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 Feb 2016 14:55:06 -0800 Subject: [PATCH 1664/1967] 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 1665/1967] 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 adb64ef8d5406c26834b2bd0a4dfab1de47ff9ca Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 5 Feb 2016 20:07:04 -0500 Subject: [PATCH 1666/1967] 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 1667/1967] 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 1668/1967] 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 1669/1967] 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 1670/1967] 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 1671/1967] 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 1672/1967] 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 1673/1967] 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 1674/1967] 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 1675/1967] 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 1676/1967] 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 1677/1967] 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 1678/1967] 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 1679/1967] 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 1680/1967] 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 1681/1967] 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 1682/1967] 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 1683/1967] 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 1684/1967] 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 1685/1967] 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 1686/1967] =?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 1687/1967] 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 1688/1967] =?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 1689/1967] 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 1690/1967] 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 1691/1967] 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 1692/1967] 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 1693/1967] 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 1694/1967] 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 1695/1967] 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 1696/1967] 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 1697/1967] 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 1698/1967] 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 1699/1967] 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 1700/1967] 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 1701/1967] 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 1702/1967] 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 1703/1967] 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 1704/1967] 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 1705/1967] 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 1706/1967] 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 1707/1967] 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 1708/1967] 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 1709/1967] 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 1710/1967] 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 1711/1967] 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 1712/1967] 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 1713/1967] 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 1714/1967] 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 1715/1967] 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 1716/1967] 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 1717/1967] 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 1718/1967] 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 1719/1967] 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 1720/1967] 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 1721/1967] 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 1722/1967] 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 1723/1967] 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 1724/1967] 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 1725/1967] 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 1726/1967] 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 1727/1967] 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 1728/1967] 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 1729/1967] 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 1730/1967] 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 1731/1967] 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 1732/1967] 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 1733/1967] 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 1734/1967] 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 1735/1967] 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 1736/1967] 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 1737/1967] 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 1738/1967] 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 1739/1967] 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 1740/1967] 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 1741/1967] 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 1742/1967] 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 1743/1967] 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 1744/1967] 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 1745/1967] 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 1746/1967] 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 1747/1967] 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 1748/1967] 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 1749/1967] 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 1750/1967] 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 1751/1967] 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 1752/1967] 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 1753/1967] 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 1754/1967] 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 1755/1967] 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 1756/1967] 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 1757/1967] 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 1758/1967] 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 1759/1967] 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 1760/1967] 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 1761/1967] 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 1762/1967] 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 1763/1967] 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 1764/1967] 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 1765/1967] 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 1766/1967] 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 1767/1967] 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 1768/1967] 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 1769/1967] 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 1770/1967] 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 1771/1967] 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 1772/1967] 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 1773/1967] 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 1774/1967] 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 1775/1967] 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 1776/1967] 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 1777/1967] 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 1778/1967] 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 1779/1967] 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 1780/1967] 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 0f1fb42326cb000efe6f06f7c1974430c474afe0 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 18:52:28 +0100 Subject: [PATCH 1781/1967] 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 1782/1967] 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 1783/1967] 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 1784/1967] 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 1785/1967] 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 1786/1967] 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 1787/1967] 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 1788/1967] 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 1789/1967] 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 1790/1967] 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 1791/1967] 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 1792/1967] 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 1793/1967] 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 1794/1967] 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 1795/1967] 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 1796/1967] 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 1797/1967] 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 1798/1967] 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 1799/1967] 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 1800/1967] 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 1801/1967] 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 1802/1967] 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 1803/1967] 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 1804/1967] 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 1805/1967] 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 1806/1967] 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 1807/1967] 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 1808/1967] 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 1809/1967] 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 1810/1967] 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 1811/1967] 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 1812/1967] 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 1813/1967] 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 7781f62ddf54fa635890c1772e1729ff5461fd55 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 Apr 2016 12:03:16 +0100 Subject: [PATCH 1814/1967] 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 1815/1967] 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 1816/1967] 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 1817/1967] 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 50287722f2dd9df322122395e76e7778e185cdec Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 13 Apr 2016 12:57:22 -0400 Subject: [PATCH 1818/1967] 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 1819/1967] 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 1820/1967] 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 1821/1967] 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 1822/1967] 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 1823/1967] 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 1824/1967] 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 1825/1967] 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 1826/1967] 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 1827/1967] 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 1828/1967] 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 1829/1967] 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 1830/1967] 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 1831/1967] 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 1832/1967] 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 1833/1967] 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 1834/1967] 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 1835/1967] 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 1836/1967] 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 1837/1967] 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 1838/1967] 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 1839/1967] 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 1840/1967] 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 1841/1967] 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 1842/1967] 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 1843/1967] 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 1844/1967] 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 1845/1967] 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 1846/1967] 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 1847/1967] 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 4b01f6dcd657636a6d05f453dfd58a6d3826ca5e Mon Sep 17 00:00:00 2001 From: Anton Simernia Date: Mon, 9 May 2016 18:15:32 +0700 Subject: [PATCH 1848/1967] 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 1849/1967] 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 1850/1967] 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 1851/1967] 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 1852/1967] 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 1853/1967] 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 1854/1967] 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 1855/1967] 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 1856/1967] 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 1857/1967] 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 1858/1967] 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 1859/1967] 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 1860/1967] 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 1861/1967] 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 1862/1967] 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 1863/1967] 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 1864/1967] 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 dd3590180da36f5359d6463003b49ea2fca90315 Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Tue, 31 May 2016 21:18:42 +0000 Subject: [PATCH 1865/1967] 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 ea640f38217e5d3796bbca49a5a1870582139d8d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 May 2016 16:32:54 -0700 Subject: [PATCH 1866/1967] 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 1867/1967] 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 1868/1967] 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 1869/1967] 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 1870/1967] 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 1871/1967] 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 1872/1967] 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 1873/1967] 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 1874/1967] 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 1875/1967] 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 020d46ff21764a57fa21e4f3ccc1ba09a1345049 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 14 Jun 2016 16:03:56 -0700 Subject: [PATCH 1876/1967] 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 1877/1967] 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 1878/1967] 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 1879/1967] 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 1880/1967] 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 1881/1967] 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 1882/1967] 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 1883/1967] 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 1884/1967] 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 1885/1967] 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 1886/1967] 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 1887/1967] 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 1888/1967] 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 1889/1967] 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 1890/1967] 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 1891/1967] 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 1892/1967] 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 1893/1967] 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 1894/1967] 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 1895/1967] 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 1896/1967] 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 1897/1967] 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 1898/1967] 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 1899/1967] 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 1900/1967] 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 1901/1967] 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 1902/1967] 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 1903/1967] 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 1904/1967] 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 1905/1967] 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 1906/1967] 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 1907/1967] 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 1908/1967] 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 1909/1967] 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 1910/1967] 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 1911/1967] 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 1912/1967] 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 1913/1967] 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 08127625a0de1a50bf4e1a1f9861c669fe778be4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 6 Jul 2016 18:01:27 -0700 Subject: [PATCH 1914/1967] 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 49d4fd27952433feb20bc22117aba4766c15c1c1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 7 Jul 2016 11:41:11 -0700 Subject: [PATCH 1915/1967] 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 1916/1967] 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 1917/1967] 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 1918/1967] 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 1919/1967] 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 1920/1967] 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 1921/1967] 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 1922/1967] 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 1923/1967] 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 1924/1967] 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 1925/1967] 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 1926/1967] 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 1927/1967] 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 1928/1967] 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 07e2426d89750d8eddabe9537d527ac46197e753 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jul 2016 13:38:04 +0100 Subject: [PATCH 1929/1967] 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 1930/1967] 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 1931/1967] 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 1932/1967] 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 1933/1967] 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 1934/1967] 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 1935/1967] 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 1936/1967] 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 1937/1967] 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 1938/1967] 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 1939/1967] 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 ec825af3d336a55297250b3a2af63b48fabed177 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jul 2016 18:26:40 +0100 Subject: [PATCH 1940/1967] 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 22c0779a498ee701c22b857669d3f43a0d404f27 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 14 Jun 2016 11:33:45 -0700 Subject: [PATCH 1941/1967] 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 1942/1967] 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 1943/1967] 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 1944/1967] 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 1945/1967] 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 1946/1967] 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 1947/1967] 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 1948/1967] 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 1949/1967] 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 1950/1967] 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 1951/1967] 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 1952/1967] 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 1953/1967] 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 1954/1967] 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 1955/1967] 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 817c76c8e9ebbeb864ef69e8e03819fefb1a784d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Sep 2016 11:17:39 -0700 Subject: [PATCH 1956/1967] 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 1957/1967] 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 1958/1967] 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 1959/1967] 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 1960/1967] 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 1961/1967] 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 1962/1967] 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 1963/1967] 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 1964/1967] 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 1965/1967] 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 79116592668be2c22d0bc188e1f1d92ff8be104c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Sep 2016 16:39:30 -0700 Subject: [PATCH 1966/1967] 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 1967/1967] 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',