Skip to content

Commit

Permalink
Interpolate environment variables
Browse files Browse the repository at this point in the history
Signed-off-by: Aanand Prasad <aanand.prasad@gmail.com>
  • Loading branch information
aanand committed Jul 30, 2015
1 parent 8fd7d31 commit 172fec2
Show file tree
Hide file tree
Showing 6 changed files with 179 additions and 4 deletions.
6 changes: 4 additions & 2 deletions compose/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions compose/config/interpolation.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions docs/yml.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ As with `docker run`, options specified in the Dockerfile (e.g., `CMD`,
`EXPOSE`, `VOLUME`, `ENV`) are respected by default - you don't need to
specify them again in `docker-compose.yml`.

Values for configuration options can contain environment variables, e.g.
`image: postgres:${POSTGRES_VERSION}`. For more details, see the section on
[variable substitution](#variable-substitution).

### image

Tag or partial image ID. Can be local or remote - Compose will attempt to
Expand Down Expand Up @@ -361,6 +365,33 @@ Each of these is a single value, analogous to its
read_only: true


## Variable substitution

Your configuration options can contain environment variables. Compose uses the
variable values from the shell environment in which `docker-compose` is run. For
example, suppose the shell contains `POSTGRES_VERSION=9.3` and you supply this
configuration:

db:
image: "postgres:${POSTGRES_VERSION}"

When you run `docker-compose up` with this configuration, Compose looks for the
`POSTGRES_VERSION` environment variable in the shell and substitutes its value
in. For this example, Compose resolves the `image` to `postgres:9.3` before
running the configuration.

If an environment variable is not set, Compose substitutes with an empty
string. In the example above, if `POSTGRES_VERSION` is not set, the value for
the `image` option is `postgres:`.

Both `$VARIABLE` and `${VARIABLE}` syntax are supported. Extended shell-style
features, such as `${VARIABLE-default}` and `${VARIABLE/foo/bar}`, are not
supported.

If you need to put a literal dollar sign in a configuration value, use a double
dollar sign (`$$`).


## Compose documentation

- [User guide](/)
Expand Down
17 changes: 17 additions & 0 deletions tests/fixtures/environment-interpolation/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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}"
43 changes: 41 additions & 2 deletions tests/unit/config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/interpolation_test.py
Original file line number Diff line number Diff line change
@@ -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()))

0 comments on commit 172fec2

Please sign in to comment.