diff --git a/compose/cli/command.py b/compose/cli/command.py index e829b25b2d..9e6384c2a9 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -71,14 +71,15 @@ def get_client(self, verbose=False): def get_project(self, config_path, project_name=None, verbose=False): try: + config_dict = config.load(config_path) return Project.from_dicts( - self.get_project_name(config_path, project_name), - config.load(config_path), + self.get_project_name(config_dict, project_name), + config_dict, self.get_client(verbose=verbose)) except ConfigError as e: raise errors.UserError(six.text_type(e)) - def get_project_name(self, config_path, project_name=None): + def get_project_name(self, config_dict, project_name=None): def normalize_name(name): return re.sub(r'[^a-z0-9]', '', name.lower()) @@ -90,9 +91,10 @@ def normalize_name(name): if project_name is not None: return normalize_name(project_name) - project = os.path.basename(os.path.dirname(os.path.abspath(config_path))) - if project: - return normalize_name(project) + if config_dict: + project_name = config_dict['project'] + if project_name: + return normalize_name(project_name) return 'default' diff --git a/compose/config.py b/compose/config.py index 022069fdf2..2422779fd7 100644 --- a/compose/config.py +++ b/compose/config.py @@ -1,6 +1,8 @@ import os import yaml import six +import compose +from distutils.version import StrictVersion DOCKER_CONFIG_KEYS = [ @@ -56,22 +58,68 @@ def load(filename): 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) - loader = ServiceLoader(working_dir=working_dir, filename=filename) - service_dict = loader.make_service_dict(service_name, service_dict) - service_dicts.append(service_dict) + if not isinstance(dictionary, dict) or 'version' not in dictionary or not isinstance(dictionary['version'], (basestring, int, float)): + dictionary = { + 'version': 1.0, + 'services': dictionary + } - return service_dicts + loader = ConfigLoader(working_dir=working_dir, filename=filename) + config_dict = loader.make_config_dict(dictionary) + return config_dict def make_service_dict(name, service_dict, working_dir=None): return ServiceLoader(working_dir=working_dir).make_service_dict(name, service_dict) +class ConfigLoader(object): + def __init__(self, working_dir, filename=None): + self.working_dir = working_dir + self.filename = filename + self.service_loader = ServiceLoader(working_dir=self.working_dir, filename=self.filename) + + def make_config_dict(self, config_dict): + if 'version' not in config_dict: + raise ConfigurationError('The configuration option "version" is compulsory. You docker-compose.yml must have a top level key "version"') + + try: + schema_version = StrictVersion(str(config_dict['version'])) + except: + raise ConfigurationError('The configuration version "%s" is not a valid version.' % config_dict['version']) + + if schema_version > StrictVersion(compose.__version__): + raise ConfigurationError('The configuration version "%s" is not compatible with the current version of docker-compose "%s".' % (config_dict['version'], compose.__version__)) + + config = { + 'version': str(schema_version), + 'project': None if self.filename is None else os.path.basename(os.path.dirname(os.path.abspath(self.filename))) + } + + if schema_version >= StrictVersion('1.0'): + if 'services' not in config_dict: + raise ConfigurationError('The configuration option "services" is compulsory. You docker-compose.yml must have a top level key "services"') + + if not isinstance(config_dict['services'], dict): + raise ConfigurationError('The configuration option "services" is not a valide dictionnary.') + + config['services'] = [] + for service_name, service_dict in list(config_dict['services'].items()): + if not isinstance(service_dict, dict): + raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All keys defined in the section service in your docker-compose.yml must map to a config_dict of configuration options.' % service_name) + service_dict = self.service_loader.make_service_dict(service_name, service_dict) + config['services'].append(service_dict) + + if schema_version >= StrictVersion('1.1'): + if 'project' in config_dict: + if not isinstance(config_dict['project'], basestring): + raise ConfigurationError('The configuration option "project" must be a valid string') + config['project'] = str(config_dict['project']) + + return config + + class ServiceLoader(object): def __init__(self, working_dir, filename=None, already_seen=None): self.working_dir = working_dir diff --git a/compose/project.py b/compose/project.py index 7c0d19da39..eb8fb4f111 100644 --- a/compose/project.py +++ b/compose/project.py @@ -61,12 +61,12 @@ def __init__(self, name, services, client): self.client = client @classmethod - def from_dicts(cls, name, service_dicts, client): + def from_dicts(cls, name, config_dict, client): """ Construct a ServiceCollection from a list of dicts representing services. """ project = cls(name, [], client) - for service_dict in sort_service_dicts(service_dicts): + for service_dict in sort_service_dicts(config_dict['services']): links = project.get_links(service_dict) volumes_from = project.get_volumes_from(service_dict) net = project.get_net(service_dict) diff --git a/docs/cli.md b/docs/cli.md index 30f8217714..ed0cadefcd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -140,7 +140,8 @@ By default, if there are existing containers for a service, `docker-compose up` ### -p, --project-name NAME - Specifies an alternate project name (default: current directory name) + Specifies an alternate project name (default: when defined, used the option + project in `docker-compose.yml` else use current directory name) ## Environment Variables diff --git a/docs/django.md b/docs/django.md index 0605c86b69..7728b6e03e 100644 --- a/docs/django.md +++ b/docs/django.md @@ -43,17 +43,19 @@ 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 - web: - build: . - command: python manage.py runserver 0.0.0.0:8000 - volumes: - - .:/code - ports: - - "8000:8000" - links: - - db + version: 1.0 + services: + db: + image: postgres + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/code + ports: + - "8000:8000" + links: + - db See the [`docker-compose.yml` reference](yml.html) for more information on how this file works. diff --git a/docs/index.md b/docs/index.md index a75e7285a2..44d38b9695 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,14 +31,16 @@ 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: - build: . - links: - - db - ports: - - "8000:8000" -db: - image: postgres +version: 1.0 +services: + web: + build: . + links: + - db + ports: + - "8000:8000" + db: + image: postgres ``` Lastly, run `docker-compose up` and Compose will start and run your entire app. @@ -120,17 +122,19 @@ and the Next, define a set of services using `docker-compose.yml`: - web: - build: . - command: python app.py - ports: - - "5000:5000" - volumes: - - .:/code - links: - - redis - redis: - image: redis + version: 1.0 + services: + web: + build: . + command: python app.py + ports: + - "5000:5000" + volumes: + - .:/code + links: + - redis + redis: + image: redis This defines two services: diff --git a/docs/rails.md b/docs/rails.md index 0671d0624a..7b4738b6f2 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -33,19 +33,21 @@ Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten 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 - ports: - - "5432" - web: - build: . - command: bundle exec rails s -p 3000 -b '0.0.0.0' - volumes: - - .:/myapp - ports: - - "3000:3000" - links: - - db + version: 1.0 + services: + db: + image: postgres + ports: + - "5432" + web: + build: . + command: bundle exec rails s -p 3000 -b '0.0.0.0' + volumes: + - .:/myapp + ports: + - "3000:3000" + links: + - db ### Build the project diff --git a/docs/wordpress.md b/docs/wordpress.md index 5a9c37a8d3..e41bdbb3cd 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -37,19 +37,21 @@ 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: 1.0 +services: + 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 diff --git a/docs/yml.md b/docs/yml.md index 157ba4e67d..6419bce427 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -188,28 +188,32 @@ Here's a simple example. Suppose we have 2 files - **common.yml** and **common.yml** ``` -webapp: - build: ./webapp - environment: - - DEBUG=false - - SEND_EMAILS=false +version: 1.0 +services: + 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 +version: 1.0 +services: + 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 @@ -220,15 +224,17 @@ environment variables (DEBUG) with a new value, and the other one this: ```yaml -web: - build: ./webapp - ports: - - "8000:8000" - links: - - db - environment: - - DEBUG=true - - SEND_EMAILS=false +version: 1.0 +services: + web: + build: ./webapp + ports: + - "8000:8000" + links: + - db + environment: + - DEBUG=true + - SEND_EMAILS=false ``` The `extends` option is great for sharing configuration between different @@ -237,22 +243,35 @@ 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 +version: 1.0 +services: + 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. +### project + +> **Note:** Since version 1.2 + +The `project` option lets you defined an project name for your services. + +``` +version: 1.1 +project: secretproject +``` + ### net Networking mode. Use the same values as the docker client `--net` parameter. diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 00d156b370..985ddf4415 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -7,19 +7,18 @@ 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=service_dicts, + config_dict=config.from_dictionary({ + 'data': { + 'image': 'busybox:latest', + 'volumes': ['/var/data'], + }, + 'db': { + 'image': 'busybox:latest', + 'volumes_from': ['data'], + }, + }, working_dir='.'), client=self.client, ) db = project.get_service('db') @@ -35,7 +34,7 @@ def test_volumes_from_container(self): ) project = Project.from_dicts( name='composetest', - service_dicts=config.from_dictionary({ + config_dict=config.from_dictionary({ 'db': { 'image': 'busybox:latest', 'volumes_from': ['composetest_data_container'], @@ -52,7 +51,7 @@ def test_volumes_from_container(self): def test_net_from_service(self): project = Project.from_dicts( name='composetest', - service_dicts=config.from_dictionary({ + config_dict=config.from_dictionary({ 'net': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"] @@ -86,7 +85,7 @@ def test_net_from_container(self): project = Project.from_dicts( name='composetest', - service_dicts=config.from_dictionary({ + config_dict=config.from_dictionary({ 'web': { 'image': 'busybox:latest', 'net': 'container:composetest_net_container' @@ -261,7 +260,7 @@ def test_project_up_starts_links(self): def test_project_up_starts_depends(self): project = Project.from_dicts( name='composetest', - service_dicts=config.from_dictionary({ + config_dict=config.from_dictionary({ 'console': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], @@ -299,7 +298,7 @@ def test_project_up_starts_depends(self): def test_project_up_with_no_deps(self): project = Project.from_dicts( name='composetest', - service_dicts=config.from_dictionary({ + config_dict=config.from_dictionary({ 'console': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index fcb55a6731..ee63e05ec5 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -13,6 +13,7 @@ from compose.cli.main import TopLevelCommand from compose.cli.errors import ComposeFileNotFound from compose.service import Service +from compose import config class CLITestCase(unittest.TestCase): @@ -22,7 +23,7 @@ def test_default_project_name(self): try: os.chdir('tests/fixtures/simple-composefile') command = TopLevelCommand() - project_name = command.get_project_name(command.get_config_path()) + project_name = command.get_project_name(config.load(command.get_config_path())) self.assertEquals('simplecomposefile', project_name) finally: os.chdir(cwd) @@ -30,13 +31,13 @@ def test_default_project_name(self): 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.get_config_path()) + project_name = command.get_project_name(config.load(command.get_config_path())) 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.get_config_path()) + project_name = command.get_project_name(config.load(command.get_config_path())) self.assertEquals('uppercasedir', project_name) def test_project_name_with_explicit_project_name(self): diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index af3bebb333..d6ad832572 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -7,13 +7,41 @@ class ConfigTest(unittest.TestCase): def test_from_dictionary(self): - service_dicts = config.from_dictionary({ + config_dict = config.from_dictionary({ + 'version': '1.1', + 'project': 'foo', + 'services': { + 'foo': {'image': 'busybox'}, + 'bar': {'environment': ['FOO=1']}, + } + }) + + self.assertEqual(config_dict['version'], '1.1') + self.assertEqual(config_dict['project'], 'foo') + self.assertEqual( + sorted(config_dict['services'], key=lambda d: d['name']), + sorted([ + { + 'name': 'bar', + 'environment': {'FOO': '1'}, + }, + { + 'name': 'foo', + 'image': 'busybox', + } + ]) + ) + + def test_from_dictionary_fallback_1_0(self): + config_dict = config.from_dictionary({ 'foo': {'image': 'busybox'}, 'bar': {'environment': ['FOO=1']}, }) + self.assertEqual(config_dict['version'], '1.0') + self.assertIsNone(config_dict['project']) self.assertEqual( - sorted(service_dicts, key=lambda d: d['name']), + sorted(config_dict['services'], key=lambda d: d['name']), sorted([ { 'name': 'bar', @@ -26,10 +54,68 @@ def test_from_dictionary(self): ]) ) - def test_from_dictionary_throws_error_when_not_dict(self): + def test_from_dictionary_fallback_service_version(self): + config_dict = config.from_dictionary({ + 'version': {'image': 'busybox'}, + }) + + self.assertEqual( + sorted(config_dict['services'], key=lambda d: d['name']), + sorted([ + { + 'name': 'version', + 'image': 'busybox', + } + ]) + ) + + def test_from_dictionary_throws_error_when_wrong_version(self): + with self.assertRaises(config.ConfigurationError): + config.from_dictionary({ + 'version': 'foo' + }) + + def test_from_dictionary_throws_error_when_version_to_hight(self): + with self.assertRaises(config.ConfigurationError): + config.from_dictionary({ + 'version': '99.0' + }) + + def test_from_dictionary_throws_error_when_missing_service(self): + with self.assertRaises(config.ConfigurationError): + config.from_dictionary({ + 'version': '1.0' + }) + + def test_from_dictionary_throws_error_when_wrong_project(self): with self.assertRaises(config.ConfigurationError): config.from_dictionary({ - 'web': 'busybox:latest', + 'version': '1.1', + 'project': ['foo'] + }) + + def test_from_dictionary_with_project_in_wrong_version(self): + config_dict = config.from_dictionary({ + 'version': '1.0', + 'project': 'foo', + 'services': {} + }) + self.assertIsNone(config_dict['project']) + + def test_from_dictionary_throws_error_when_services_not_dict(self): + with self.assertRaises(config.ConfigurationError): + config.from_dictionary({ + 'version': '1.0', + 'services': 'foo' + }) + + def test_from_dictionary_throws_error_when_service_not_dict(self): + with self.assertRaises(config.ConfigurationError): + config.from_dictionary({ + 'version': '1.0', + 'services': { + 'web': 'busybox:latest', + } }) def test_config_validation(self): @@ -267,10 +353,10 @@ def test_resolve_environment_from_file(self): class ExtendsTest(unittest.TestCase): def test_extends(self): - service_dicts = config.load('tests/fixtures/extends/docker-compose.yml') + config_dict = config.load('tests/fixtures/extends/docker-compose.yml') service_dicts = sorted( - service_dicts, + config_dict['services'], key=lambda sd: sd['name'], ) @@ -294,9 +380,9 @@ def test_extends(self): ]) def test_nested(self): - service_dicts = config.load('tests/fixtures/extends/nested.yml') + config_dict = config.load('tests/fixtures/extends/nested.yml') - self.assertEqual(service_dicts, [ + self.assertEqual(config_dict['services'], [ { 'name': 'myweb', 'image': 'busybox', @@ -382,4 +468,4 @@ def test_volume_path(self): '%s:/bar' % os.path.abspath('tests/fixtures/volume-path/bar'), ] - self.assertEqual(set(dicts[0]['volumes']), set(paths)) + self.assertEqual(set(dicts['services'][0]['volumes']), set(paths)) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index d5c5acb780..bb577397a4 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -11,7 +11,7 @@ class ProjectTest(unittest.TestCase): def test_from_dict(self): - project = Project.from_dicts('composetest', [ + project = Project.from_dicts('composetest', {'services': [ { 'name': 'web', 'image': 'busybox:latest' @@ -20,7 +20,7 @@ def test_from_dict(self): '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') @@ -28,7 +28,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('composetest', [ + project = Project.from_dicts('composetest', {'services': [ { 'name': 'web', 'image': 'busybox:latest', @@ -44,7 +44,7 @@ def test_from_dict_sorts_in_dependency_order(self): 'image': 'busybox:latest', 'volumes': ['/tmp'], } - ], None) + ]}, None) self.assertEqual(project.services[0].name, 'volume') self.assertEqual(project.services[1].name, 'db') @@ -146,13 +146,13 @@ def test_use_volumes_from_container(self): 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', [ + project = Project.from_dicts('test', {'services': [ { 'name': 'test', 'image': 'busybox:latest', 'volumes_from': ['aaa'] } - ], mock_client) + ]}, mock_client) self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id]) def test_use_volumes_from_service_no_container(self): @@ -166,7 +166,7 @@ def test_use_volumes_from_service_no_container(self): "Image": 'busybox:latest' } ] - project = Project.from_dicts('test', [ + project = Project.from_dicts('test', {'services': [ { 'name': 'vol', 'image': 'busybox:latest' @@ -176,7 +176,7 @@ def test_use_volumes_from_service_no_container(self): 'image': 'busybox:latest', 'volumes_from': ['vol'] } - ], mock_client) + ]}, mock_client) self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name]) @mock.patch.object(Service, 'containers') @@ -186,7 +186,7 @@ def test_use_volumes_from_service_container(self, mock_return): mock.Mock(id=container_id, spec=Container) for container_id in container_ids] - project = Project.from_dicts('test', [ + project = Project.from_dicts('test', {'services': [ { 'name': 'vol', 'image': 'busybox:latest' @@ -196,7 +196,7 @@ def test_use_volumes_from_service_container(self, mock_return): 'image': 'busybox:latest', 'volumes_from': ['vol'] } - ], None) + ]}, None) self.assertEqual(project.get_service('test')._get_volumes_from(), container_ids) def test_use_net_from_container(self): @@ -204,13 +204,13 @@ def test_use_net_from_container(self): 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', [ + project = Project.from_dicts('test', {'services': [ { 'name': 'test', 'image': 'busybox:latest', 'net': 'container:aaa' } - ], mock_client) + ]}, mock_client) service = project.get_service('test') self.assertEqual(service._get_net(), 'container:' + container_id) @@ -225,7 +225,7 @@ def test_use_net_from_service(self): "Image": 'busybox:latest' } ] - project = Project.from_dicts('test', [ + project = Project.from_dicts('test', {'services': [ { 'name': 'aaa', 'image': 'busybox:latest' @@ -235,7 +235,7 @@ def test_use_net_from_service(self): 'image': 'busybox:latest', 'net': 'container:aaa' } - ], mock_client) + ]}, mock_client) service = project.get_service('test') self.assertEqual(service._get_net(), 'container:' + container_name)