diff --git a/docs/yml.md b/docs/yml.md index 3096ba835..bd6914f8a 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -120,6 +120,21 @@ environment: - SESSION_SECRET ``` +### env_file + +Add environment variables from a file. Can be a single value or a list. + +Environment variables specified in `environment` override these values. + +``` +env_file: + - .env +``` + +``` +RACK_ENV: development +``` + ### net Networking mode. Use the same values as the docker client `--net` parameter. diff --git a/fig/service.py b/fig/service.py index 645b6adfc..6622db83a 100644 --- a/fig/service.py +++ b/fig/service.py @@ -15,7 +15,7 @@ from .progress_stream import stream_output, StreamOutputError log = logging.getLogger(__name__) -DOCKER_CONFIG_KEYS = ['image', 'command', 'hostname', 'domainname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'dns', 'volumes', 'entrypoint', 'privileged', 'volumes_from', 'net', 'working_dir', 'restart', 'cap_add', 'cap_drop'] +DOCKER_CONFIG_KEYS = ['image', 'command', 'hostname', 'domainname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'env_file', 'dns', 'volumes', 'entrypoint', 'privileged', 'volumes_from', 'net', 'working_dir', 'restart', 'cap_add', 'cap_drop'] DOCKER_CONFIG_HINTS = { 'link' : 'links', 'port' : 'ports', @@ -372,10 +372,7 @@ class Service(object): (parse_volume_spec(v).internal, {}) for v in container_options['volumes']) - if 'environment' in container_options: - if isinstance(container_options['environment'], list): - container_options['environment'] = dict(split_env(e) for e in container_options['environment']) - container_options['environment'] = dict(resolve_env(k, v) for k, v in container_options['environment'].iteritems()) + container_options['environment'] = merge_environment(container_options) if self.can_be_built(): if len(self.client.images(name=self._build_tag_name())) == 0: @@ -383,7 +380,7 @@ class Service(object): container_options['image'] = self._build_tag_name() # Delete options which are only used when starting - for key in ['privileged', 'net', 'dns', 'restart', 'cap_add', 'cap_drop']: + for key in ['privileged', 'net', 'dns', 'restart', 'cap_add', 'cap_drop', 'env_file']: if key in container_options: del container_options[key] @@ -543,6 +540,25 @@ def split_port(port): return internal_port, (external_ip, external_port or None) +def merge_environment(options): + env = {} + + if 'env_file' in options: + if isinstance(options['env_file'], list): + for f in options['env_file']: + env.update(env_vars_from_file(f)) + else: + env.update(env_vars_from_file(options['env_file'])) + + if 'environment' in options: + if isinstance(options['environment'], list): + env.update(dict(split_env(e) for e in options['environment'])) + else: + env.update(options['environment']) + + return dict(resolve_env(k, v) for k, v in env.iteritems()) + + def split_env(env): if '=' in env: return env.split('=', 1) @@ -557,3 +573,16 @@ def resolve_env(key, val): return key, os.environ[key] else: return key, '' + + +def env_vars_from_file(filename): + """ + Read in a line delimited file of environment variables. + """ + env = {} + for line in open(filename, 'r'): + line = line.strip() + if line and not line.startswith('#'): + k, v = split_env(line) + env[k] = v + return env diff --git a/tests/fixtures/env/one.env b/tests/fixtures/env/one.env new file mode 100644 index 000000000..75a4f62ff --- /dev/null +++ b/tests/fixtures/env/one.env @@ -0,0 +1,4 @@ +ONE=2 +TWO=1 +THREE=3 +FOO=bar diff --git a/tests/fixtures/env/resolve.env b/tests/fixtures/env/resolve.env new file mode 100644 index 000000000..720520d29 --- /dev/null +++ b/tests/fixtures/env/resolve.env @@ -0,0 +1,4 @@ +FILE_DEF=F1 +FILE_DEF_EMPTY= +ENV_DEF +NO_DEF diff --git a/tests/fixtures/env/two.env b/tests/fixtures/env/two.env new file mode 100644 index 000000000..3b21871a0 --- /dev/null +++ b/tests/fixtures/env/two.env @@ -0,0 +1,2 @@ +FOO=baz +DOO=dah diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 9d3e0b126..3eae62ae6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -397,6 +397,12 @@ class ServiceTest(DockerClientTestCase): for k,v in {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}.iteritems(): self.assertEqual(env[k], v) + def test_env_from_file_combined_with_env(self): + service = self.create_service('web', environment=['ONE=1', 'TWO=2', 'THREE=3'], env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) + env = service.start_container().environment + for k,v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.iteritems(): + self.assertEqual(env[k], v) + def test_resolve_env(self): service = self.create_service('web', environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}) os.environ['FILE_DEF'] = 'E1' diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 119b41440..e562ebc37 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -247,3 +247,72 @@ class ServiceVolumesTest(unittest.TestCase): self.assertEqual( binding, ('/home/user', dict(bind='/home/user', ro=False))) + +class ServiceEnvironmentTest(unittest.TestCase): + + def setUp(self): + self.mock_client = mock.create_autospec(docker.Client) + self.mock_client.containers.return_value = [] + + def test_parse_environment(self): + service = Service('foo', + environment=['NORMAL=F1', 'CONTAINS_EQUALS=F=2', 'TRAILING_EQUALS='], + client=self.mock_client, + ) + options = service._get_container_create_options({}) + self.assertEqual( + options['environment'], + {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''} + ) + + @mock.patch.dict(os.environ) + def test_resolve_environment(self): + os.environ['FILE_DEF'] = 'E1' + os.environ['FILE_DEF_EMPTY'] = 'E2' + os.environ['ENV_DEF'] = 'E3' + service = Service('foo', + environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}, + client=self.mock_client, + ) + options = service._get_container_create_options({}) + self.assertEqual( + options['environment'], + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''} + ) + + def test_env_from_file(self): + service = Service('foo', + env_file='tests/fixtures/env/one.env', + client=self.mock_client, + ) + options = service._get_container_create_options({}) + self.assertEqual( + options['environment'], + {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'} + ) + + def test_env_from_multiple_files(self): + service = Service('foo', + env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env'], + client=self.mock_client, + ) + options = service._get_container_create_options({}) + self.assertEqual( + options['environment'], + {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'} + ) + + @mock.patch.dict(os.environ) + def test_resolve_environment_from_file(self): + os.environ['FILE_DEF'] = 'E1' + os.environ['FILE_DEF_EMPTY'] = 'E2' + os.environ['ENV_DEF'] = 'E3' + service = Service('foo', + env_file=['tests/fixtures/env/resolve.env'], + client=self.mock_client, + ) + options = service._get_container_create_options({}) + self.assertEqual( + options['environment'], + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''} + )