Skip to content

Commit

Permalink
Add support for environment variables with backoff values in fig.yml
Browse files Browse the repository at this point in the history
This change resolves #495 by introducing a Config class inside the cli
library. The Config class is responsible for loading a .yml file and
evaluating its contents for environment variables. The Config immediately
reloads the data as a dictionary, which reduces compatibility issues within
previously written code. This behavior also prevents race conditions
caused by changes to the environment during long-running fig builds.

This change is valuable because it allows for a greater degree of
automation and variability when working with multiple environments
or deployment types that use identically built containers. We
currently have to define separate services for each degree of
variability we want to account for.

Future steps for this change may include introducing template files,
or adding access and manipulation APIs directly so that we can
perpetuate the config class everywhere we are currently using
bare dictionaries.

Added in revision requests from  @dnephin:

* Terminology fix in docs/yml.md
* Refactored config class into a series of functions
* Updated tests to reflect functional interpolation
* Improved forward compatibility

Added in revision requests from @thaJeztah

* Terminology fixes

Added in revision requests from @aanand

* Support for escaping

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.

Signed-off-by: Robert Elwell <robert.elwell@gmail.com>
Sponsored by: Lookout, Inc.
  • Loading branch information
Robert Elwell committed Jan 15, 2015
1 parent 72095f5 commit ab81a7a
Show file tree
Hide file tree
Showing 4 changed files with 234 additions and 9 deletions.
23 changes: 23 additions & 0 deletions docs/yml.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ Each service defined in `fig.yml` must specify exactly one of `image` or `build`

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 `fig.yml`.

Fig supports using environment variables with optional defaults as values. The accepted patterns are as follows:

```
${ENVIRONMENT_VARIABLE:default}
or simply
${ENVIRONMENT_VARIABLE}
```

###image

Tag or partial image ID. Can be local or remote - Fig will attempt to pull if it doesn't exist locally.
Expand All @@ -18,6 +26,7 @@ Tag or partial image ID. Can be local or remote - Fig will attempt to pull if it
image: ubuntu
image: orchardup/postgresql
image: a4bc65fd
image: ${SPECIFIED_OS:ubuntu}
```

### build
Expand All @@ -26,6 +35,8 @@ Path to a directory containing a Dockerfile. Fig will build and tag it with a ge

```
build: /path/to/build/dir
or
build: ${BUILD_PATH:/path/to/build/dir}
```

### command
Expand All @@ -34,6 +45,7 @@ Override the default command.

```
command: bundle exec thin -p 3000
command: RACK_ENV=${RACK_ENV:production} bundle exec rackup -p 80
```

<a name="links"></a>
Expand All @@ -46,6 +58,7 @@ links:
- db
- db:database
- redis
- ${SEARCH_SERVICE:solr}:search
```

An entry with the alias' name will be created in `/etc/hosts` inside containers for this service, e.g:
Expand All @@ -54,6 +67,7 @@ An entry with the alias' name will be created in `/etc/hosts` inside containers
172.17.2.186 db
172.17.2.186 database
172.17.2.187 redis
172.17.2.188 search
```

Environment variables will also be created - see the [environment variable reference](env.html) for details.
Expand Down Expand Up @@ -82,6 +96,7 @@ ports:
- "8000:8000"
- "49100:22"
- "127.0.0.1:8001:8001"
- ${DEFAULT_PORT:"80:800"}
```

### expose
Expand All @@ -92,6 +107,7 @@ Expose ports without publishing them to the host machine - they'll only be acces
expose:
- "3000"
- "8000"
- ${EXPOSE_PORT}
```

### volumes
Expand All @@ -104,6 +120,7 @@ volumes:
- /var/lib/mysql
- cache/:/tmp/cache
- ~/configs:/etc/configs/:ro
- ${JAVA_HOME:/usr/lib/jvm/jdk1.7.0/}/lib
```

### volumes_from
Expand All @@ -114,6 +131,7 @@ Mount all of the volumes from another service or container.
volumes_from:
- service_name
- container_name
- ${SELECTED_SERVICE_VOLUME:service_name}
```

### environment
Expand All @@ -125,10 +143,12 @@ Environment variables with only a key are resolved to their values on the machin
```
environment:
RACK_ENV: development
LOGLEVEL: ${APP_LOGLEVEL:INFO}
SESSION_SECRET:
environment:
- RACK_ENV=development
- LOGLEVEL=${APP_LOGLEVEL:INFO}
- SESSION_SECRET
```

Expand Down Expand Up @@ -156,6 +176,7 @@ net: "bridge"
net: "none"
net: "container:[name or id]"
net: "host"
net: ${NET_MODE:"bridge"}
```

### dns
Expand All @@ -164,6 +185,7 @@ Custom DNS servers. Can be a single value or a list.

```
dns: 8.8.8.8
dns: ${DNS_SERVER:8.8.8.8}
dns:
- 8.8.8.8
- 9.9.9.9
Expand Down Expand Up @@ -197,6 +219,7 @@ dns_search:
### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares

Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart.
Each entry can use environment variables.

```
cpu_shares: 73
Expand Down
21 changes: 12 additions & 9 deletions fig/cli/command.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
from __future__ import unicode_literals
from __future__ import absolute_import
from requests.exceptions import ConnectionError, SSLError
import errno
import logging
import os
import re
import yaml
import six

from ..project import Project
Expand All @@ -15,6 +13,7 @@
from .docker_client import docker_client
from . import verbose_proxy
from . import errors
from .config import from_yaml_with_environment_vars
from .. import __version__

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -67,13 +66,17 @@ def get_client(self, verbose=False):
return client

def get_config(self, config_path):
try:
with open(config_path, 'r') as fh:
return yaml.safe_load(fh)
except IOError as e:
if e.errno == errno.ENOENT:
raise errors.FigFileNotFound(os.path.basename(e.filename))
raise errors.UserError(six.text_type(e))
"""
Access a :class:fig.cli.config.Config object from string representing a file location,
returned as a dict with any variables appropriately interpolated
:param config_path: the full path, including filename where fig.yml lives
:type config_path: str
:return: dict
:rtype: dict
"""
return from_yaml_with_environment_vars(config_path)

def get_project(self, config_path, project_name=None, verbose=False):
try:
Expand Down
73 changes: 73 additions & 0 deletions fig/cli/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from __future__ import unicode_literals
from __future__ import absolute_import

import six
import errno
import yaml
import os
import re

from . import errors


def resolve_environment_vars(value):
"""
Matches our environment variable pattern, replaces the value with the value in the env
A future improvement here would be to also reference a separate dictionary that keeps track of
configurable variables via flat file.
:param value: any value that is reachable by a key in a dict represented by a yaml file
:return: the value itself if not a string with an environment variable, otherwise the value specified in the env
"""
if not isinstance(value, six.string_types):
return value

env_regex = r'([^\\])?\$\{(?P<env_var>[^\}^:]+)(:(?P<default_val>[^\}]+))?\}'

match_object = re.match(env_regex, value)
if match_object is None:
return value

result = os.environ.get(match_object.group('env_var'), match_object.group('default_val'))

if result is None:
raise errors.UserError("No value for ${%s} found in environment." % (match_object.group('env_var'))
+ "Please set a value in the environment or provide a default.")

return re.sub(env_regex, result, value)


def with_environment_vars(value):
"""
Recursively interpolates environment variables for a structured or unstructured value
:param value: a dict, list, or any other kind of value
:return: the dict with its values interpolated from the env
:rtype: dict
"""
if type(value) == dict:
return dict([(subkey, with_environment_vars(subvalue))
for subkey, subvalue in value.items()])
elif type(value) == list:
return [resolve_environment_vars(x) for x in value]
else:
return resolve_environment_vars(value)


def from_yaml_with_environment_vars(yaml_filename):
"""
Resolves enviornment variables in values defined by a YAML file and transformed into a dict
:param yaml_filename: the name of the yaml file
:type yaml_filename: str
:return: a dict with environment variables properly interpolated
"""
try:
with open(yaml_filename, 'r') as fh:
return with_environment_vars(yaml.safe_load(fh))
except IOError as e:
if e.errno == errno.ENOENT:
raise errors.FigFileNotFound(os.path.basename(e.filename))
raise errors.UserError(six.text_type(e))
126 changes: 126 additions & 0 deletions tests/unit/cli/config_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from __future__ import unicode_literals
from __future__ import absolute_import
from tests import unittest

from fig.cli import config
from fig.cli.errors import UserError

import yaml
import os


TEST_YML_DICT_WITH_DEFAULTS = yaml.load("""db:
image: postgres
web:
build: ${DJANGO_BUILD:.}
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/code
environment:
DJANGO_ENV: ${TEST_VALUE:production}
ports:
- ${DJANGO_PORT:"8000:8000"}
links:
- db""")

TEST_YML_DICT_NO_DEFAULTS = yaml.load("""db:
image: postgres
web:
build: ${TEST_VALUE}
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/code
ports:
- "8000:8000"
links:
- db""")


class ConfigTestCase(unittest.TestCase):

def setUp(self):
self.former_test_value = os.environ.get('TEST_VALUE')

def tearDown(self):
if self.former_test_value is not None:
os.environ['TEST_VALUE'] = self.former_test_value
elif 'TEST_VALUE' in os.environ:
del os.environ['TEST_VALUE']

def test_with_resolve_environment_vars_nonmatch_values(self):
# It should just return non-string values
self.assertEqual(1, config.resolve_environment_vars(1))
self.assertEqual([], config.resolve_environment_vars([]))
self.assertEqual({}, config.resolve_environment_vars({}))
self.assertEqual(1.234, config.resolve_environment_vars(1.234))
self.assertEqual(None, config.resolve_environment_vars(None))

# Any string that doesn't match our regex should just be returned
self.assertEqual('localhost', config.resolve_environment_vars('localhost'))

expected = "some-other-host"
os.environ['TEST_VALUE'] = expected

# Bare mentions of the environment variable shouldn't work
value = 'TEST_VALUE:foo'
self.assertEqual(value, config.resolve_environment_vars(value))

value = 'TEST_VALUE'
self.assertEqual(value, config.resolve_environment_vars(value))

# Incomplete pattern shouldn't work as well
for value in ['${TEST_VALUE', '$TEST_VALUE', '{TEST_VALUE}']:
self.assertEqual(value, config.resolve_environment_vars(value))
value += ':foo'
self.assertEqual(value, config.resolve_environment_vars(value))

def test_fully_interpolated_matches(self):
expected = "some-other-host"
os.environ['TEST_VALUE'] = expected

# if we have a basic match
self.assertEqual(expected, config.resolve_environment_vars("${TEST_VALUE}"))

# if we have a match with a default value
self.assertEqual(expected, config.resolve_environment_vars("${TEST_VALUE:localhost}"))

# escaping should prevent interpolation
escaped_no_backoff = "\${TEST_VALUE}"
escaped_with_backoff = "\${TEST_VALUE:localhost}"
self.assertEqual(escaped_no_backoff, escaped_no_backoff)
self.assertEqual(escaped_with_backoff, escaped_with_backoff)

# if we have no match but a default value
del os.environ['TEST_VALUE']
self.assertEqual('localhost', config.resolve_environment_vars("${TEST_VALUE:localhost}"))

def test_fully_interpolated_errors(self):
if 'TEST_VALUE' in os.environ:
del os.environ['TEST_VALUE']
self.assertRaises(UserError, config.resolve_environment_vars, "${TEST_VALUE}")

def test_functional_defaults_as_dict(self):
d = config.with_environment_vars(TEST_YML_DICT_WITH_DEFAULTS)

# tests the basic structure and functionality of defaults
self.assertEqual(d['web']['build'], '.')

# test that environment variables with defaults are handled in lists
self.assertEqual(d['web']['ports'][0], '"8000:8000"')

# test that environment variables with defaults are handled in dictionaries
self.assertEqual(d['web']['environment']['DJANGO_ENV'], 'production')

# test that having an environment variable set properly pulls it
os.environ['TEST_VALUE'] = 'development'
d = config.with_environment_vars(TEST_YML_DICT_WITH_DEFAULTS)
self.assertEqual(d['web']['environment']['DJANGO_ENV'], 'development')

def test_functional_no_defaults(self):
# test that not having defaults raises an error in a real YML situation
self.assertRaises(UserError, config.with_environment_vars, TEST_YML_DICT_NO_DEFAULTS)

# test that a bare environment variable is interpolated
# note that we have to reload it
os.environ['TEST_VALUE'] = '/home/ubuntu/django'
self.assertEqual(config.with_environment_vars(TEST_YML_DICT_NO_DEFAULTS)['web']['build'], '/home/ubuntu/django')

0 comments on commit ab81a7a

Please sign in to comment.