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)
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 = self.get_project(
explicit_config_path,
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)
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_config_path(file_option):
if file_option:
return file_option
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))
if 'FIG_FILE' in os.environ:
log.warn('The FIG_FILE environment variable is deprecated.')
log.warn('Please use COMPOSE_FILE instead.')
def get_project_name(self, working_dir, project_name=None):
def normalize_name(name):
return re.sub(r'[^a-z0-9]', '', name.lower())
config_file = os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE')
return [config_file] if config_file else None
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)
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
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.
Usage:
docker-compose [options] [COMMAND] [ARGS...]
docker-compose [-f=<arg>...] [options] [COMMAND] [ARGS...]
docker-compose -h|--help
Options:

View File

@ -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,

View File

@ -16,7 +16,6 @@ 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
from compose.cli.utils import find_candidates_in_parent_dirs
DOCKER_CONFIG_KEYS = [
@ -77,6 +76,7 @@ SUPPORTED_FILENAMES = [
'fig.yaml',
]
DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml'
PATH_START_CHARS = [
'/',
@ -88,24 +88,45 @@ PATH_START_CHARS = [
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):
if filename == '-':
return ConfigDetails(yaml.safe_load(sys.stdin), os.getcwd(), None)
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`
"""
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_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])
def get_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]
@ -123,7 +144,31 @@ def get_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):
"""
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
@ -133,29 +178,49 @@ 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
"""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)
validate_against_fields_schema(processed_config)
Return a fully interpolated, extended and validated configuration.
"""
service_dicts = []
for service_name, service_dict in list(processed_config.items()):
def build_service(filename, service_name, service_dict):
loader = ServiceLoader(
working_dir=working_dir,
filename=filename,
service_name=service_name,
service_dict=service_dict)
config_details.working_dir,
filename,
service_name,
service_dict)
service_dict = loader.make_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):

View File

@ -14,7 +14,7 @@ weight=-2
```
Usage:
docker-compose [options] [COMMAND] [ARGS...]
docker-compose [-f=<arg>...] [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

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 .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 @@ class CLITestCase(DockerClientTestCase):
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 @@ class CLITestCase(DockerClientTestCase):
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)
@ -584,7 +585,6 @@ class CLITestCase(DockerClientTestCase):
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(
@ -607,7 +607,7 @@ class CLITestCase(DockerClientTestCase):
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)
@ -628,6 +628,44 @@ class CLITestCase(DockerClientTestCase):
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)

View File

@ -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 @@ from compose.service import ConvergenceStrategy
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):

View File

@ -9,7 +9,7 @@ import shutil
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 @@ class ProjectTestCase(DockerClientTestCase):
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):

View File

@ -4,9 +4,12 @@ from __future__ import unicode_literals
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 @@ from compose.service import Service
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)

View File

View File

@ -5,10 +5,10 @@ import shutil
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):
@ -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 @@ class ConfigTest(unittest.TestCase):
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 @@ class ConfigTest(unittest.TestCase):
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,17 +85,54 @@ class ConfigTest(unittest.TestCase):
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'
)
)
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(
config.ConfigDetails(
build_config_details(
{valid_name: {'image': 'busybox'}},
'tests/fixtures/extends',
'common.yml'
@ -101,7 +144,7 @@ class ConfigTest(unittest.TestCase):
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 +155,7 @@ class ConfigTest(unittest.TestCase):
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 +166,7 @@ class ConfigTest(unittest.TestCase):
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 +179,7 @@ class ConfigTest(unittest.TestCase):
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 +192,7 @@ class ConfigTest(unittest.TestCase):
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 +205,7 @@ class ConfigTest(unittest.TestCase):
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 +216,7 @@ class ConfigTest(unittest.TestCase):
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 +230,7 @@ class ConfigTest(unittest.TestCase):
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 +243,7 @@ class ConfigTest(unittest.TestCase):
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 +255,7 @@ class ConfigTest(unittest.TestCase):
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 +270,7 @@ class ConfigTest(unittest.TestCase):
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load(
config.ConfigDetails(
build_config_details(
{'web': {
'image': 'busybox',
'extra_hosts': [
@ -244,7 +287,7 @@ class ConfigTest(unittest.TestCase):
expose_values = [["8000"], [8000]]
for expose in expose_values:
service = config.load(
config.ConfigDetails(
build_config_details(
{'web': {
'image': 'busybox',
'expose': expose
@ -259,7 +302,7 @@ class ConfigTest(unittest.TestCase):
entrypoint_values = [["sh"], "sh"]
for entrypoint in entrypoint_values:
service = config.load(
config.ConfigDetails(
build_config_details(
{'web': {
'image': 'busybox',
'entrypoint': entrypoint
@ -274,7 +317,7 @@ class ConfigTest(unittest.TestCase):
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}
@ -292,7 +335,7 @@ class ConfigTest(unittest.TestCase):
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load(
config.ConfigDetails(
build_config_details(
{'web': {
'image': 'busybox',
'environment': {'---': 'nope'}
@ -331,16 +374,16 @@ class InterpolationTest(unittest.TestCase):
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 +398,7 @@ class InterpolationTest(unittest.TestCase):
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 +414,10 @@ class InterpolationTest(unittest.TestCase):
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 +692,7 @@ class MemoryOptionsTest(unittest.TestCase):
)
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load(
config.ConfigDetails(
build_config_details(
{
'foo': {'image': 'busybox', 'memswap_limit': 2000000},
},
@ -660,7 +703,7 @@ class MemoryOptionsTest(unittest.TestCase):
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 +713,7 @@ class MemoryOptionsTest(unittest.TestCase):
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 +823,26 @@ class EnvTest(unittest.TestCase):
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 +928,7 @@ class ExtendsTest(unittest.TestCase):
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 +940,7 @@ class ExtendsTest(unittest.TestCase):
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 +953,7 @@ class ExtendsTest(unittest.TestCase):
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 +973,7 @@ class ExtendsTest(unittest.TestCase):
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 +998,7 @@ class ExtendsTest(unittest.TestCase):
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 +1136,7 @@ class BuildPathTest(unittest.TestCase):
def test_nonexistent_path(self):
with self.assertRaises(ConfigurationError):
config.load(
config.ConfigDetails(
build_config_details(
{
'foo': {'build': 'nonexistent.path'},
},
@ -1124,7 +1167,7 @@ class BuildPathTest(unittest.TestCase):
self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}])
class GetConfigPathTestCase(unittest.TestCase):
class GetDefaultConfigFilesTestCase(unittest.TestCase):
files = [
'docker-compose.yml',
@ -1134,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([])
@ -1170,6 +1209,7 @@ def get_config_filename_for_files(filenames, subdir=None):
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)