Merge pull request #2051 from dnephin/extend_compose_files

Extend compose files by allowing multiple files
This commit is contained in:
Aanand Prasad 2015-09-21 12:34:22 +02:00
commit 18dbe1b1c0
14 changed files with 391 additions and 191 deletions

View File

@ -51,57 +51,68 @@ class Command(DocoptCommand):
handler(None, command_options) handler(None, command_options)
return return
if 'FIG_FILE' in os.environ: project = get_project(
log.warn('The FIG_FILE environment variable is deprecated.') self.base_dir,
log.warn('Please use COMPOSE_FILE instead.') get_config_path(options.get('--file')),
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,
project_name=options.get('--project-name'), project_name=options.get('--project-name'),
verbose=options.get('--verbose')) verbose=options.get('--verbose'))
handler(project, command_options) 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): def get_config_path(file_option):
config_details = config.find(self.base_dir, config_path) if file_option:
return file_option
try: if 'FIG_FILE' in os.environ:
return Project.from_dicts( log.warn('The FIG_FILE environment variable is deprecated.')
self.get_project_name(config_details.working_dir, project_name), log.warn('Please use COMPOSE_FILE instead.')
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): config_file = os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE')
def normalize_name(name): return [config_file] if config_file else None
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 = ( def get_client(verbose=False):
project_name or client = docker_client()
os.environ.get('COMPOSE_PROJECT_NAME') or if verbose:
os.environ.get('FIG_PROJECT_NAME')) version_info = six.iteritems(client.version())
if project_name is not None: log.info("Compose version %s", __version__)
return normalize_name(project_name) 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
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'

View File

@ -96,7 +96,7 @@ class TopLevelCommand(Command):
"""Define and run multi-container applications with Docker. """Define and run multi-container applications with Docker.
Usage: Usage:
docker-compose [options] [COMMAND] [ARGS...] docker-compose [-f=<arg>...] [options] [COMMAND] [ARGS...]
docker-compose -h|--help docker-compose -h|--help
Options: Options:

View File

@ -36,25 +36,6 @@ def yesno(prompt, default=None):
return 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): def split_buffer(reader, separator):
""" """
Given a generator which yields strings and a separator string, Given a generator which yields strings and a separator string,

View File

@ -16,7 +16,6 @@ from .validation import validate_extended_service_exists
from .validation import validate_extends_file_path from .validation import validate_extends_file_path
from .validation import validate_service_names from .validation import validate_service_names
from .validation import validate_top_level_object from .validation import validate_top_level_object
from compose.cli.utils import find_candidates_in_parent_dirs
DOCKER_CONFIG_KEYS = [ DOCKER_CONFIG_KEYS = [
@ -77,6 +76,7 @@ SUPPORTED_FILENAMES = [
'fig.yaml', 'fig.yaml',
] ]
DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml'
PATH_START_CHARS = [ PATH_START_CHARS = [
'/', '/',
@ -88,24 +88,45 @@ PATH_START_CHARS = [
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
ConfigDetails = namedtuple('ConfigDetails', 'config working_dir filename') 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`
"""
def find(base_dir, filename): class ConfigFile(namedtuple('_ConfigFile', 'filename config')):
if filename == '-': """
return ConfigDetails(yaml.safe_load(sys.stdin), os.getcwd(), None) :param filename: filename of the config file
:type filename: string
:param config: contents of the config file
:type config: :class:`dict`
"""
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: else:
filename = get_config_path(base_dir) filenames = get_default_config_files(base_dir)
return ConfigDetails(load_yaml(filename), os.path.dirname(filename), filename)
log.debug("Using configuration files: {}".format(",".join(filenames)))
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_files(base_dir):
(candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir) (candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir)
if len(candidates) == 0: if not candidates:
raise ComposeFileNotFound(SUPPORTED_FILENAMES) raise ComposeFileNotFound(SUPPORTED_FILENAMES)
winner = candidates[0] winner = candidates[0]
@ -123,7 +144,31 @@ def get_config_path(base_dir):
log.warn("%s is deprecated and will not be supported in future. " log.warn("%s is deprecated and will not be supported in future. "
"Please rename your config file to docker-compose.yml\n" % winner) "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):
"""
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 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)
return (candidates, path)
@validate_top_level_object @validate_top_level_object
@ -133,29 +178,49 @@ def pre_process_config(config):
Pre validation checks and processing of the config file to interpolate env Pre validation checks and processing of the config file to interpolate env
vars returning a config dict ready to be tested against the schema. vars returning a config dict ready to be tested against the schema.
""" """
config = interpolate_environment_variables(config) return interpolate_environment_variables(config)
return config
def load(config_details): def load(config_details):
config, working_dir, filename = 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.
processed_config = pre_process_config(config) Return a fully interpolated, extended and validated configuration.
validate_against_fields_schema(processed_config) """
service_dicts = [] def build_service(filename, service_name, service_dict):
for service_name, service_dict in list(processed_config.items()):
loader = ServiceLoader( loader = ServiceLoader(
working_dir=working_dir, config_details.working_dir,
filename=filename, filename,
service_name=service_name, service_name,
service_dict=service_dict) service_dict)
service_dict = loader.make_service_dict() service_dict = loader.make_service_dict()
validate_paths(service_dict) validate_paths(service_dict)
service_dicts.append(service_dict) return service_dict
return service_dicts 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):
all_service_names = set(base) | set(override)
return {
name: merge_service_dicts(base.get(name, {}), override.get(name, {}))
for name in all_service_names
}
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 load_file(config_file.filename, config_file.config)
class ServiceLoader(object): class ServiceLoader(object):

View File

@ -14,7 +14,7 @@ weight=-2
``` ```
Usage: Usage:
docker-compose [options] [COMMAND] [ARGS...] docker-compose [-f=<arg>...] [options] [COMMAND] [ARGS...]
docker-compose -h|--help docker-compose -h|--help
Options: Options:
@ -41,20 +41,62 @@ Commands:
unpause Unpause services unpause Unpause services
up Create and start containers up Create and start containers
migrate-to-labels Recreate containers to add labels 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 Use the `-f` flag to specify the location of a Compose configuration file. You
flag is optional. If you don't provide this flag. Compose looks for a file named can supply multiple `-f` configuration files. When you supply multiple files,
`docker-compose.yml` in the working directory. If the file is not found, Compose combines them into a single configuration. Compose builds the
Compose looks in each parent directory successively, until it finds the file. 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 For example, consider this command line:
used all paths in the configuration are relative to the current working
directory. ```
$ 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 ## Where to go next

View File

@ -0,0 +1,6 @@
web:
command: "top"
db:
command: "top"

View File

@ -0,0 +1,10 @@
web:
image: busybox:latest
command: "sleep 200"
links:
- db
db:
image: busybox:latest
command: "sleep 200"

View File

@ -0,0 +1,9 @@
web:
links:
- db
- other
other:
image: busybox:latest
command: "top"

View File

@ -9,6 +9,7 @@ from six import StringIO
from .. import mock from .. import mock
from .testcases import DockerClientTestCase from .testcases import DockerClientTestCase
from compose.cli.command import get_project
from compose.cli.errors import UserError from compose.cli.errors import UserError
from compose.cli.main import TopLevelCommand from compose.cli.main import TopLevelCommand
from compose.project import NoSuchService from compose.project import NoSuchService
@ -38,7 +39,7 @@ class CLITestCase(DockerClientTestCase):
if hasattr(self, '_project'): if hasattr(self, '_project'):
return self._project return self._project
return self.command.get_project() return get_project(self.command.base_dir)
def test_help(self): def test_help(self):
old_base_dir = self.command.base_dir old_base_dir = self.command.base_dir
@ -72,7 +73,7 @@ class CLITestCase(DockerClientTestCase):
def test_ps_alternate_composefile(self, mock_stdout): def test_ps_alternate_composefile(self, mock_stdout):
config_path = os.path.abspath( config_path = os.path.abspath(
'tests/fixtures/multiple-composefiles/compose2.yml') '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.base_dir = 'tests/fixtures/multiple-composefiles'
self.command.dispatch(['-f', 'compose2.yml', 'up', '-d'], None) self.command.dispatch(['-f', 'compose2.yml', 'up', '-d'], None)
@ -584,7 +585,6 @@ class CLITestCase(DockerClientTestCase):
self.assertEqual(get_port(3002), "0.0.0.0:49153") self.assertEqual(get_port(3002), "0.0.0.0:49153")
def test_port_with_scale(self): def test_port_with_scale(self):
self.command.base_dir = 'tests/fixtures/ports-composefile-scale' self.command.base_dir = 'tests/fixtures/ports-composefile-scale'
self.command.dispatch(['scale', 'simple=2'], None) self.command.dispatch(['scale', 'simple=2'], None)
containers = sorted( containers = sorted(
@ -607,7 +607,7 @@ class CLITestCase(DockerClientTestCase):
def test_env_file_relative_to_compose_file(self): def test_env_file_relative_to_compose_file(self):
config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml')
self.command.dispatch(['-f', config_path, 'up', '-d'], None) 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) containers = self.project.containers(stopped=True)
self.assertEqual(len(containers), 1) self.assertEqual(len(containers), 1)
@ -628,6 +628,44 @@ class CLITestCase(DockerClientTestCase):
self.assertTrue(components[-2:] == ['home-dir', 'my-volume'], self.assertTrue(components[-2:] == ['home-dir', 'my-volume'],
msg="Last two components differ: %s, %s" % (actual_host_path, expected_host_path)) 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): def test_up_with_extends(self):
self.command.base_dir = 'tests/fixtures/extends' self.command.base_dir = 'tests/fixtures/extends'
self.command.dispatch(['up', '-d'], None) self.command.dispatch(['up', '-d'], None)

View File

@ -1,7 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from .testcases import DockerClientTestCase from .testcases import DockerClientTestCase
from compose import config from compose.config import config
from compose.const import LABEL_PROJECT from compose.const import LABEL_PROJECT
from compose.container import Container from compose.container import Container
from compose.project import Project from compose.project import Project
@ -9,7 +9,10 @@ from compose.service import ConvergenceStrategy
def build_service_dicts(service_config): 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): class ProjectTest(DockerClientTestCase):

View File

@ -9,7 +9,7 @@ import shutil
import tempfile import tempfile
from .testcases import DockerClientTestCase from .testcases import DockerClientTestCase
from compose import config from compose.config import config
from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_CONFIG_HASH
from compose.project import Project from compose.project import Project
from compose.service import ConvergenceStrategy from compose.service import ConvergenceStrategy
@ -24,11 +24,13 @@ class ProjectTestCase(DockerClientTestCase):
return set(project.containers(stopped=True)) return set(project.containers(stopped=True))
def make_project(self, cfg): def make_project(self, cfg):
details = config.ConfigDetails(
'working_dir',
[config.ConfigFile(None, cfg)])
return Project.from_dicts( return Project.from_dicts(
name='composetest', name='composetest',
client=self.client, client=self.client,
service_dicts=config.load(config.ConfigDetails(cfg, 'working_dir', None)) service_dicts=config.load(details))
)
class BasicProjectTest(ProjectTestCase): class BasicProjectTest(ProjectTestCase):

View File

@ -4,9 +4,12 @@ from __future__ import unicode_literals
import os import os
import docker import docker
import py
from .. import mock from .. import mock
from .. import unittest 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.docopt_command import NoSuchCommand
from compose.cli.errors import UserError from compose.cli.errors import UserError
from compose.cli.main import TopLevelCommand from compose.cli.main import TopLevelCommand
@ -14,55 +17,45 @@ from compose.service import Service
class CLITestCase(unittest.TestCase): class CLITestCase(unittest.TestCase):
def test_default_project_name(self):
cwd = os.getcwd()
try: def test_default_project_name(self):
os.chdir('tests/fixtures/simple-composefile') test_dir = py._path.local.LocalPath('tests/fixtures/simple-composefile')
command = TopLevelCommand() with test_dir.as_cwd():
project_name = command.get_project_name('.') project_name = get_project_name('.')
self.assertEquals('simplecomposefile', project_name) self.assertEquals('simplecomposefile', project_name)
finally:
os.chdir(cwd)
def test_project_name_with_explicit_base_dir(self): def test_project_name_with_explicit_base_dir(self):
command = TopLevelCommand() base_dir = 'tests/fixtures/simple-composefile'
command.base_dir = 'tests/fixtures/simple-composefile' project_name = get_project_name(base_dir)
project_name = command.get_project_name(command.base_dir)
self.assertEquals('simplecomposefile', project_name) self.assertEquals('simplecomposefile', project_name)
def test_project_name_with_explicit_uppercase_base_dir(self): def test_project_name_with_explicit_uppercase_base_dir(self):
command = TopLevelCommand() base_dir = 'tests/fixtures/UpperCaseDir'
command.base_dir = 'tests/fixtures/UpperCaseDir' project_name = get_project_name(base_dir)
project_name = command.get_project_name(command.base_dir)
self.assertEquals('uppercasedir', project_name) self.assertEquals('uppercasedir', project_name)
def test_project_name_with_explicit_project_name(self): def test_project_name_with_explicit_project_name(self):
command = TopLevelCommand()
name = 'explicit-project-name' 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) self.assertEquals('explicitprojectname', project_name)
def test_project_name_from_environment_old_var(self): def test_project_name_from_environment_old_var(self):
command = TopLevelCommand()
name = 'namefromenv' name = 'namefromenv'
with mock.patch.dict(os.environ): with mock.patch.dict(os.environ):
os.environ['FIG_PROJECT_NAME'] = name os.environ['FIG_PROJECT_NAME'] = name
project_name = command.get_project_name(None) project_name = get_project_name(None)
self.assertEquals(project_name, name) self.assertEquals(project_name, name)
def test_project_name_from_environment_new_var(self): def test_project_name_from_environment_new_var(self):
command = TopLevelCommand()
name = 'namefromenv' name = 'namefromenv'
with mock.patch.dict(os.environ): with mock.patch.dict(os.environ):
os.environ['COMPOSE_PROJECT_NAME'] = name os.environ['COMPOSE_PROJECT_NAME'] = name
project_name = command.get_project_name(None) project_name = get_project_name(None)
self.assertEquals(project_name, name) self.assertEquals(project_name, name)
def test_get_project(self): def test_get_project(self):
command = TopLevelCommand() base_dir = 'tests/fixtures/longer-filename-composefile'
command.base_dir = 'tests/fixtures/longer-filename-composefile' project = get_project(base_dir)
project = command.get_project()
self.assertEqual(project.name, 'longerfilenamecomposefile') self.assertEqual(project.name, 'longerfilenamecomposefile')
self.assertTrue(project.client) self.assertTrue(project.client)
self.assertTrue(project.services) self.assertTrue(project.services)

View File

View File

@ -5,10 +5,10 @@ import shutil
import tempfile import tempfile
from operator import itemgetter from operator import itemgetter
from .. import mock
from .. import unittest
from compose.config import config from compose.config import config
from compose.config.errors import ConfigurationError 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): def make_service_dict(name, service_dict, working_dir, filename=None):
@ -26,10 +26,16 @@ def service_sort(services):
return sorted(services, key=itemgetter('name')) 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): class ConfigTest(unittest.TestCase):
def test_load(self): def test_load(self):
service_dicts = config.load( service_dicts = config.load(
config.ConfigDetails( build_config_details(
{ {
'foo': {'image': 'busybox'}, 'foo': {'image': 'busybox'},
'bar': {'image': 'busybox', 'environment': ['FOO=1']}, 'bar': {'image': 'busybox', 'environment': ['FOO=1']},
@ -57,7 +63,7 @@ class ConfigTest(unittest.TestCase):
def test_load_throws_error_when_not_dict(self): def test_load_throws_error_when_not_dict(self):
with self.assertRaises(ConfigurationError): with self.assertRaises(ConfigurationError):
config.load( config.load(
config.ConfigDetails( build_config_details(
{'web': 'busybox:latest'}, {'web': 'busybox:latest'},
'working_dir', 'working_dir',
'filename.yml' 'filename.yml'
@ -68,7 +74,7 @@ class ConfigTest(unittest.TestCase):
with self.assertRaises(ConfigurationError): with self.assertRaises(ConfigurationError):
for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']:
config.load( config.load(
config.ConfigDetails( build_config_details(
{invalid_name: {'image': 'busybox'}}, {invalid_name: {'image': 'busybox'}},
'working_dir', 'working_dir',
'filename.yml' 'filename.yml'
@ -79,17 +85,54 @@ class ConfigTest(unittest.TestCase):
expected_error_msg = "Service name: 1 needs to be a string, eg '1'" expected_error_msg = "Service name: 1 needs to be a string, eg '1'"
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{1: {'image': 'busybox'}}, {1: {'image': 'busybox'}},
'working_dir', 'working_dir',
'filename.yml' 'filename.yml'
) )
) )
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): def test_config_valid_service_names(self):
for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']:
config.load( config.load(
config.ConfigDetails( build_config_details(
{valid_name: {'image': 'busybox'}}, {valid_name: {'image': 'busybox'}},
'tests/fixtures/extends', 'tests/fixtures/extends',
'common.yml' 'common.yml'
@ -101,7 +144,7 @@ class ConfigTest(unittest.TestCase):
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): 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"]]:
config.load( config.load(
config.ConfigDetails( build_config_details(
{'web': {'image': 'busybox', 'ports': invalid_ports}}, {'web': {'image': 'busybox', 'ports': invalid_ports}},
'working_dir', 'working_dir',
'filename.yml' 'filename.yml'
@ -112,7 +155,7 @@ class ConfigTest(unittest.TestCase):
valid_ports = [["8000", "9000"], ["8000/8050"], ["8000"], [8000], ["49153-49154:3002-3003"]] valid_ports = [["8000", "9000"], ["8000/8050"], ["8000"], [8000], ["49153-49154:3002-3003"]]
for ports in valid_ports: for ports in valid_ports:
config.load( config.load(
config.ConfigDetails( build_config_details(
{'web': {'image': 'busybox', 'ports': ports}}, {'web': {'image': 'busybox', 'ports': ports}},
'working_dir', 'working_dir',
'filename.yml' 'filename.yml'
@ -123,7 +166,7 @@ class ConfigTest(unittest.TestCase):
expected_error_msg = "(did you mean 'privileged'?)" expected_error_msg = "(did you mean 'privileged'?)"
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'foo': {'image': 'busybox', 'privilige': 'something'}, 'foo': {'image': 'busybox', 'privilige': 'something'},
}, },
@ -136,7 +179,7 @@ class ConfigTest(unittest.TestCase):
expected_error_msg = "Service 'foo' has both an image and build path specified." expected_error_msg = "Service 'foo' has both an image and build path specified."
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'foo': {'image': 'busybox', 'build': '.'}, 'foo': {'image': 'busybox', 'build': '.'},
}, },
@ -149,7 +192,7 @@ class ConfigTest(unittest.TestCase):
expected_error_msg = "Service 'foo' configuration key 'links' contains an invalid type, 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): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'foo': {'image': 'busybox', 'links': 'an_link'}, 'foo': {'image': 'busybox', 'links': 'an_link'},
}, },
@ -162,7 +205,7 @@ class ConfigTest(unittest.TestCase):
expected_error_msg = "Top level object needs to be a dictionary." expected_error_msg = "Top level object needs to be a dictionary."
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
['foo', 'lol'], ['foo', 'lol'],
'tests/fixtures/extends', 'tests/fixtures/extends',
'filename.yml' 'filename.yml'
@ -173,7 +216,7 @@ class ConfigTest(unittest.TestCase):
expected_error_msg = "has non-unique elements" expected_error_msg = "has non-unique elements"
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'web': {'build': '.', 'devices': ['/dev/foo:/dev/foo', '/dev/foo:/dev/foo']} 'web': {'build': '.', 'devices': ['/dev/foo:/dev/foo', '/dev/foo:/dev/foo']}
}, },
@ -187,7 +230,7 @@ class ConfigTest(unittest.TestCase):
expected_error_msg += ", which is an invalid type, it should be a string" expected_error_msg += ", which is an invalid type, it should be a string"
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'web': {'build': '.', 'command': [1]} 'web': {'build': '.', 'command': [1]}
}, },
@ -200,7 +243,7 @@ class ConfigTest(unittest.TestCase):
expected_error_msg = "Service 'web' has both an image and alternate Dockerfile." expected_error_msg = "Service 'web' has both an image and alternate Dockerfile."
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{'web': {'image': 'busybox', 'dockerfile': 'Dockerfile.alt'}}, {'web': {'image': 'busybox', 'dockerfile': 'Dockerfile.alt'}},
'working_dir', 'working_dir',
'filename.yml' 'filename.yml'
@ -212,7 +255,7 @@ class ConfigTest(unittest.TestCase):
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{'web': { {'web': {
'image': 'busybox', 'image': 'busybox',
'extra_hosts': 'somehost:162.242.195.82' 'extra_hosts': 'somehost:162.242.195.82'
@ -227,7 +270,7 @@ class ConfigTest(unittest.TestCase):
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{'web': { {'web': {
'image': 'busybox', 'image': 'busybox',
'extra_hosts': [ 'extra_hosts': [
@ -244,7 +287,7 @@ class ConfigTest(unittest.TestCase):
expose_values = [["8000"], [8000]] expose_values = [["8000"], [8000]]
for expose in expose_values: for expose in expose_values:
service = config.load( service = config.load(
config.ConfigDetails( build_config_details(
{'web': { {'web': {
'image': 'busybox', 'image': 'busybox',
'expose': expose 'expose': expose
@ -259,7 +302,7 @@ class ConfigTest(unittest.TestCase):
entrypoint_values = [["sh"], "sh"] entrypoint_values = [["sh"], "sh"]
for entrypoint in entrypoint_values: for entrypoint in entrypoint_values:
service = config.load( service = config.load(
config.ConfigDetails( build_config_details(
{'web': { {'web': {
'image': 'busybox', 'image': 'busybox',
'entrypoint': entrypoint 'entrypoint': entrypoint
@ -274,7 +317,7 @@ class ConfigTest(unittest.TestCase):
def test_logs_warning_for_boolean_in_environment(self, mock_logging): 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, True in the 'environment' key."
config.load( config.load(
config.ConfigDetails( build_config_details(
{'web': { {'web': {
'image': 'busybox', 'image': 'busybox',
'environment': {'SHOW_STUFF': True} 'environment': {'SHOW_STUFF': True}
@ -292,7 +335,7 @@ class ConfigTest(unittest.TestCase):
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{'web': { {'web': {
'image': 'busybox', 'image': 'busybox',
'environment': {'---': 'nope'} 'environment': {'---': 'nope'}
@ -331,16 +374,16 @@ class InterpolationTest(unittest.TestCase):
def test_unset_variable_produces_warning(self): def test_unset_variable_produces_warning(self):
os.environ.pop('FOO', None) os.environ.pop('FOO', None)
os.environ.pop('BAR', None) os.environ.pop('BAR', None)
config_details = config.ConfigDetails( config_details = build_config_details(
config={ {
'web': { 'web': {
'image': '${FOO}', 'image': '${FOO}',
'command': '${BAR}', 'command': '${BAR}',
'container_name': '${BAR}', 'container_name': '${BAR}',
}, },
}, },
working_dir='.', '.',
filename=None, None,
) )
with mock.patch('compose.config.interpolation.log') as log: with mock.patch('compose.config.interpolation.log') as log:
@ -355,7 +398,7 @@ class InterpolationTest(unittest.TestCase):
def test_invalid_interpolation(self): def test_invalid_interpolation(self):
with self.assertRaises(config.ConfigurationError) as cm: with self.assertRaises(config.ConfigurationError) as cm:
config.load( config.load(
config.ConfigDetails( build_config_details(
{'web': {'image': '${'}}, {'web': {'image': '${'}},
'working_dir', 'working_dir',
'filename.yml' 'filename.yml'
@ -371,10 +414,10 @@ class InterpolationTest(unittest.TestCase):
def test_volume_binding_with_environment_variable(self): def test_volume_binding_with_environment_variable(self):
os.environ['VOLUME_PATH'] = '/host/path' os.environ['VOLUME_PATH'] = '/host/path'
d = config.load( d = config.load(
config.ConfigDetails( build_config_details(
config={'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}},
working_dir='.', '.',
filename=None, None,
) )
)[0] )[0]
self.assertEqual(d['volumes'], ['/host/path:/container/path']) self.assertEqual(d['volumes'], ['/host/path:/container/path'])
@ -649,7 +692,7 @@ class MemoryOptionsTest(unittest.TestCase):
) )
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'foo': {'image': 'busybox', 'memswap_limit': 2000000}, 'foo': {'image': 'busybox', 'memswap_limit': 2000000},
}, },
@ -660,7 +703,7 @@ class MemoryOptionsTest(unittest.TestCase):
def test_validation_with_correct_memswap_values(self): def test_validation_with_correct_memswap_values(self):
service_dict = config.load( service_dict = config.load(
config.ConfigDetails( build_config_details(
{'foo': {'image': 'busybox', 'mem_limit': 1000000, 'memswap_limit': 2000000}}, {'foo': {'image': 'busybox', 'mem_limit': 1000000, 'memswap_limit': 2000000}},
'tests/fixtures/extends', 'tests/fixtures/extends',
'common.yml' 'common.yml'
@ -670,7 +713,7 @@ class MemoryOptionsTest(unittest.TestCase):
def test_memswap_can_be_a_string(self): def test_memswap_can_be_a_string(self):
service_dict = config.load( service_dict = config.load(
config.ConfigDetails( build_config_details(
{'foo': {'image': 'busybox', 'mem_limit': "1G", 'memswap_limit': "512M"}}, {'foo': {'image': 'busybox', 'mem_limit': "1G", 'memswap_limit': "512M"}},
'tests/fixtures/extends', 'tests/fixtures/extends',
'common.yml' 'common.yml'
@ -780,26 +823,26 @@ class EnvTest(unittest.TestCase):
os.environ['CONTAINERENV'] = '/host/tmp' os.environ['CONTAINERENV'] = '/host/tmp'
service_dict = config.load( service_dict = config.load(
config.ConfigDetails( build_config_details(
config={'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}, {'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}},
working_dir="tests/fixtures/env", "tests/fixtures/env",
filename=None, None,
) )
)[0] )[0]
self.assertEqual(set(service_dict['volumes']), set(['/tmp:/host/tmp'])) self.assertEqual(set(service_dict['volumes']), set(['/tmp:/host/tmp']))
service_dict = config.load( service_dict = config.load(
config.ConfigDetails( build_config_details(
config={'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, {'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}},
working_dir="tests/fixtures/env", "tests/fixtures/env",
filename=None, None,
) )
)[0] )[0]
self.assertEqual(set(service_dict['volumes']), set(['/opt/tmp:/opt/host/tmp'])) self.assertEqual(set(service_dict['volumes']), set(['/opt/tmp:/opt/host/tmp']))
def load_from_filename(filename): def load_from_filename(filename):
return config.load(config.find('.', filename)) return config.load(config.find('.', [filename]))
class ExtendsTest(unittest.TestCase): class ExtendsTest(unittest.TestCase):
@ -885,7 +928,7 @@ class ExtendsTest(unittest.TestCase):
def test_extends_validation_empty_dictionary(self): def test_extends_validation_empty_dictionary(self):
with self.assertRaisesRegexp(ConfigurationError, 'service'): with self.assertRaisesRegexp(ConfigurationError, 'service'):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'web': {'image': 'busybox', 'extends': {}}, 'web': {'image': 'busybox', 'extends': {}},
}, },
@ -897,7 +940,7 @@ class ExtendsTest(unittest.TestCase):
def test_extends_validation_missing_service_key(self): def test_extends_validation_missing_service_key(self):
with self.assertRaisesRegexp(ConfigurationError, "'service' is a required property"): with self.assertRaisesRegexp(ConfigurationError, "'service' is a required property"):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'web': {'image': 'busybox', 'extends': {'file': 'common.yml'}}, 'web': {'image': 'busybox', 'extends': {'file': 'common.yml'}},
}, },
@ -910,7 +953,7 @@ class ExtendsTest(unittest.TestCase):
expected_error_msg = "Unsupported config option for 'web' service: 'rogue_key'" expected_error_msg = "Unsupported config option for 'web' service: 'rogue_key'"
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'web': { 'web': {
'image': 'busybox', 'image': 'busybox',
@ -930,7 +973,7 @@ class ExtendsTest(unittest.TestCase):
expected_error_msg = "Service 'web' configuration key 'extends' 'file' contains an invalid type" expected_error_msg = "Service 'web' configuration key 'extends' 'file' contains an invalid type"
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'web': { 'web': {
'image': 'busybox', 'image': 'busybox',
@ -955,7 +998,7 @@ class ExtendsTest(unittest.TestCase):
def test_extends_validation_valid_config(self): def test_extends_validation_valid_config(self):
service = config.load( service = config.load(
config.ConfigDetails( build_config_details(
{ {
'web': {'image': 'busybox', 'extends': {'service': 'web', 'file': 'common.yml'}}, 'web': {'image': 'busybox', 'extends': {'service': 'web', 'file': 'common.yml'}},
}, },
@ -1093,7 +1136,7 @@ class BuildPathTest(unittest.TestCase):
def test_nonexistent_path(self): def test_nonexistent_path(self):
with self.assertRaises(ConfigurationError): with self.assertRaises(ConfigurationError):
config.load( config.load(
config.ConfigDetails( build_config_details(
{ {
'foo': {'build': 'nonexistent.path'}, 'foo': {'build': 'nonexistent.path'},
}, },
@ -1124,7 +1167,7 @@ class BuildPathTest(unittest.TestCase):
self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}])
class GetConfigPathTestCase(unittest.TestCase): class GetDefaultConfigFilesTestCase(unittest.TestCase):
files = [ files = [
'docker-compose.yml', 'docker-compose.yml',
@ -1134,25 +1177,21 @@ class GetConfigPathTestCase(unittest.TestCase):
] ]
def test_get_config_path_default_file_in_basedir(self): def test_get_config_path_default_file_in_basedir(self):
files = self.files for index, filename in enumerate(self.files):
self.assertEqual('docker-compose.yml', get_config_filename_for_files(files[0:])) self.assertEqual(
self.assertEqual('docker-compose.yaml', get_config_filename_for_files(files[1:])) filename,
self.assertEqual('fig.yml', get_config_filename_for_files(files[2:])) get_config_filename_for_files(self.files[index:]))
self.assertEqual('fig.yaml', get_config_filename_for_files(files[3:]))
with self.assertRaises(config.ComposeFileNotFound): with self.assertRaises(config.ComposeFileNotFound):
get_config_filename_for_files([]) get_config_filename_for_files([])
def test_get_config_path_default_file_in_parent_dir(self): def test_get_config_path_default_file_in_parent_dir(self):
"""Test with files placed in the subdir""" """Test with files placed in the subdir"""
files = self.files
def get_config_in_subdir(files): def get_config_in_subdir(files):
return get_config_filename_for_files(files, subdir=True) return get_config_filename_for_files(files, subdir=True)
self.assertEqual('docker-compose.yml', get_config_in_subdir(files[0:])) for index, filename in enumerate(self.files):
self.assertEqual('docker-compose.yaml', get_config_in_subdir(files[1:])) self.assertEqual(filename, get_config_in_subdir(self.files[index:]))
self.assertEqual('fig.yml', get_config_in_subdir(files[2:]))
self.assertEqual('fig.yaml', get_config_in_subdir(files[3:]))
with self.assertRaises(config.ComposeFileNotFound): with self.assertRaises(config.ComposeFileNotFound):
get_config_in_subdir([]) get_config_in_subdir([])
@ -1170,6 +1209,7 @@ def get_config_filename_for_files(filenames, subdir=None):
base_dir = tempfile.mkdtemp(dir=project_dir) base_dir = tempfile.mkdtemp(dir=project_dir)
else: else:
base_dir = project_dir 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: finally:
shutil.rmtree(project_dir) shutil.rmtree(project_dir)