From c0da04c5a362f0205d253f8b05d518d58318fd03 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 24 Jul 2015 15:58:18 +0100 Subject: [PATCH] Interpolate environment variables Signed-off-by: Aanand Prasad --- compose/config/config.py | 6 +- compose/config/interpolation.py | 55 +++++++++++++++++++ .../docker-compose.yml | 17 ++++++ tests/unit/config_test.py | 43 ++++++++++++++- tests/unit/interpolation_test.py | 31 +++++++++++ 5 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 compose/config/interpolation.py create mode 100644 tests/fixtures/environment-interpolation/docker-compose.yml create mode 100644 tests/unit/interpolation_test.py diff --git a/compose/config/config.py b/compose/config/config.py index ca9107e3da5..bda4535abd7 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -8,6 +8,7 @@ from compose.cli.utils import find_candidates_in_parent_dirs +from .interpolation import interpolate_environment_variables from .errors import ( ConfigurationError, CircularReference, @@ -160,6 +161,7 @@ def detect_cycle(self, name): def make_service_dict(self, name, service_dict): service_dict = service_dict.copy() service_dict['name'] = name + service_dict = interpolate_environment_variables(service_dict) service_dict = resolve_environment(service_dict, working_dir=self.working_dir) service_dict = self.resolve_extends(service_dict) return process_container_options(service_dict, working_dir=self.working_dir) @@ -428,9 +430,9 @@ def resolve_volume_paths(volumes, working_dir=None): def resolve_volume_path(volume, working_dir): container_path, host_path = split_path_mapping(volume) - container_path = os.path.expanduser(os.path.expandvars(container_path)) + container_path = os.path.expanduser(container_path) if host_path is not None: - host_path = os.path.expanduser(os.path.expandvars(host_path)) + host_path = os.path.expanduser(host_path) return "%s:%s" % (expand_path(working_dir, host_path), container_path) else: return container_path diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py new file mode 100644 index 00000000000..8f23b4fa14f --- /dev/null +++ b/compose/config/interpolation.py @@ -0,0 +1,55 @@ +import os +from string import Template +from collections import defaultdict + +import six + +from .errors import ConfigurationError + + +def interpolate_environment_variables(service_dict): + return dict( + (key, interpolate_value(service_dict['name'], key, val)) + for (key, val) in service_dict.items() + ) + + +def interpolate_value(service_name, config_key, value): + try: + return recursive_interpolate(value) + except InvalidInterpolation as e: + raise ConfigurationError( + 'Invalid interpolation format for "{config_key}" option ' + 'in service "{service_name}": "{string}"' + .format( + config_key=config_key, + service_name=service_name, + string=e.string, + ) + ) + + +def recursive_interpolate(obj): + if isinstance(obj, six.string_types): + return interpolate(obj, os.environ) + elif isinstance(obj, dict): + return dict( + (key, recursive_interpolate(val)) + for (key, val) in obj.items() + ) + elif isinstance(obj, list): + return map(recursive_interpolate, obj) + else: + return obj + + +def interpolate(string, mapping): + try: + return Template(string).substitute(defaultdict(lambda: "", mapping)) + except ValueError: + raise InvalidInterpolation(string) + + +class InvalidInterpolation(Exception): + def __init__(self, string): + self.string = string diff --git a/tests/fixtures/environment-interpolation/docker-compose.yml b/tests/fixtures/environment-interpolation/docker-compose.yml new file mode 100644 index 00000000000..7ed43a812cb --- /dev/null +++ b/tests/fixtures/environment-interpolation/docker-compose.yml @@ -0,0 +1,17 @@ +web: + # unbracketed name + image: $IMAGE + + # array element + ports: + - "${HOST_PORT}:8000" + + # dictionary item value + labels: + mylabel: "${LABEL_VALUE}" + + # unset value + hostname: "host-${UNSET_VALUE}" + + # escaped interpolation + command: "$${ESCAPED}" diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 54dedc83e2d..62a2eaef208 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -59,9 +59,48 @@ def test_config_validation(self): make_service_dict('foo', {'ports': ['8000']}, 'tests/') -class VolumePathTest(unittest.TestCase): +class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) - def test_volume_binding_with_environ(self): + def test_config_file_with_environment_variable(self): + os.environ.update( + IMAGE="busybox", + HOST_PORT="80", + LABEL_VALUE="myvalue", + ) + + service_dicts = config.load( + config.find('tests/fixtures/environment-interpolation', None), + ) + + self.assertEqual(service_dicts, [ + { + 'name': 'web', + 'image': 'busybox', + 'ports': ['80:8000'], + 'labels': {'mylabel': 'myvalue'}, + 'hostname': 'host-', + 'command': '${ESCAPED}', + } + ]) + + @mock.patch.dict(os.environ) + def test_invalid_interpolation(self): + with self.assertRaises(config.ConfigurationError) as cm: + config.load( + config.ConfigDetails( + {'web': {'image': '${'}}, + 'working_dir', + 'filename.yml' + ) + ) + + self.assertIn('Invalid', cm.exception.msg) + self.assertIn('for "image" option', cm.exception.msg) + self.assertIn('in service "web"', cm.exception.msg) + self.assertIn('"${"', cm.exception.msg) + + @mock.patch.dict(os.environ) + def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' d = make_service_dict('foo', {'volumes': ['${VOLUME_PATH}:/container/path']}, working_dir='.') self.assertEqual(d['volumes'], ['/host/path:/container/path']) diff --git a/tests/unit/interpolation_test.py b/tests/unit/interpolation_test.py new file mode 100644 index 00000000000..96c6f9b33a8 --- /dev/null +++ b/tests/unit/interpolation_test.py @@ -0,0 +1,31 @@ +import unittest + +from compose.config.interpolation import interpolate, InvalidInterpolation + + +class InterpolationTest(unittest.TestCase): + def test_valid_interpolations(self): + self.assertEqual(interpolate('$foo', dict(foo='hi')), 'hi') + self.assertEqual(interpolate('${foo}', dict(foo='hi')), 'hi') + + self.assertEqual(interpolate('${subject} love you', dict(subject='i')), 'i love you') + self.assertEqual(interpolate('i ${verb} you', dict(verb='love')), 'i love you') + self.assertEqual(interpolate('i love ${object}', dict(object='you')), 'i love you') + + def test_empty_value(self): + self.assertEqual(interpolate('${foo}', dict(foo='')), '') + + def test_unset_value(self): + self.assertEqual(interpolate('${foo}', dict()), '') + + def test_escaped_interpolation(self): + self.assertEqual(interpolate('$${foo}', dict(foo='hi')), '${foo}') + + def test_invalid_strings(self): + self.assertRaises(InvalidInterpolation, lambda: interpolate('${', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('$}', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('${}', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('${ }', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('${ foo}', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('${foo }', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('${foo!}', dict()))