Merge pull request #3057 from shin-/2636-env-file

Add support for a default environment file
This commit is contained in:
Daniel Nephin 2016-03-24 14:26:03 -04:00
commit aa50023507
22 changed files with 356 additions and 135 deletions

View File

@ -10,7 +10,7 @@
- id: end-of-file-fixer - id: end-of-file-fixer
- id: flake8 - id: flake8
- id: name-tests-test - id: name-tests-test
exclude: 'tests/(helpers\.py|integration/testcases\.py)' exclude: 'tests/(integration/testcases\.py|helpers\.py)'
- id: requirements-txt-fixer - id: requirements-txt-fixer
- id: trailing-whitespace - id: trailing-whitespace
- repo: git://github.com/asottile/reorder_python_imports - repo: git://github.com/asottile/reorder_python_imports

View File

@ -9,6 +9,7 @@ import six
from . import verbose_proxy from . import verbose_proxy
from .. import config from .. import config
from ..config.environment import Environment
from ..const import API_VERSIONS from ..const import API_VERSIONS
from ..project import Project from ..project import Project
from .docker_client import docker_client from .docker_client import docker_client
@ -19,29 +20,34 @@ log = logging.getLogger(__name__)
def project_from_options(project_dir, options): def project_from_options(project_dir, options):
environment = Environment.from_env_file(project_dir)
return get_project( return get_project(
project_dir, project_dir,
get_config_path_from_options(options), get_config_path_from_options(project_dir, options, environment),
project_name=options.get('--project-name'), project_name=options.get('--project-name'),
verbose=options.get('--verbose'), verbose=options.get('--verbose'),
host=options.get('--host'), host=options.get('--host'),
tls_config=tls_config_from_options(options), tls_config=tls_config_from_options(options),
environment=environment
) )
def get_config_path_from_options(options): def get_config_path_from_options(base_dir, options, environment):
file_option = options.get('--file') file_option = options.get('--file')
if file_option: if file_option:
return file_option return file_option
config_files = os.environ.get('COMPOSE_FILE') config_files = environment.get('COMPOSE_FILE')
if config_files: if config_files:
return config_files.split(os.pathsep) return config_files.split(os.pathsep)
return None return None
def get_client(verbose=False, version=None, tls_config=None, host=None): def get_client(environment, verbose=False, version=None, tls_config=None, host=None):
client = docker_client(version=version, tls_config=tls_config, host=host) client = docker_client(
version=version, tls_config=tls_config, host=host,
environment=environment
)
if verbose: if verbose:
version_info = six.iteritems(client.version()) version_info = six.iteritems(client.version())
log.info(get_version_info('full')) log.info(get_version_info('full'))
@ -53,27 +59,33 @@ def get_client(verbose=False, version=None, tls_config=None, host=None):
def get_project(project_dir, config_path=None, project_name=None, verbose=False, def get_project(project_dir, config_path=None, project_name=None, verbose=False,
host=None, tls_config=None): host=None, tls_config=None, environment=None):
config_details = config.find(project_dir, config_path) if not environment:
project_name = get_project_name(config_details.working_dir, project_name) environment = Environment.from_env_file(project_dir)
config_details = config.find(project_dir, config_path, environment)
project_name = get_project_name(
config_details.working_dir, project_name, environment
)
config_data = config.load(config_details) config_data = config.load(config_details)
api_version = os.environ.get( api_version = environment.get(
'COMPOSE_API_VERSION', 'COMPOSE_API_VERSION',
API_VERSIONS[config_data.version]) API_VERSIONS[config_data.version])
client = get_client( client = get_client(
verbose=verbose, version=api_version, tls_config=tls_config, verbose=verbose, version=api_version, tls_config=tls_config,
host=host host=host, environment=environment
) )
return Project.from_config(project_name, config_data, client) return Project.from_config(project_name, config_data, client)
def get_project_name(working_dir, project_name=None): def get_project_name(working_dir, project_name=None, environment=None):
def normalize_name(name): def normalize_name(name):
return re.sub(r'[^a-z0-9]', '', name.lower()) return re.sub(r'[^a-z0-9]', '', name.lower())
project_name = project_name or os.environ.get('COMPOSE_PROJECT_NAME') if not environment:
environment = Environment.from_env_file(working_dir)
project_name = project_name or environment.get('COMPOSE_PROJECT_NAME')
if project_name: if project_name:
return normalize_name(project_name) return normalize_name(project_name)

View File

@ -2,7 +2,6 @@ from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
import logging import logging
import os
from docker import Client from docker import Client
from docker.errors import TLSParameterError from docker.errors import TLSParameterError
@ -42,17 +41,17 @@ def tls_config_from_options(options):
return None return None
def docker_client(version=None, tls_config=None, host=None): def docker_client(environment, version=None, tls_config=None, host=None):
""" """
Returns a docker-py client configured using environment variables Returns a docker-py client configured using environment variables
according to the same logic as the official Docker client. according to the same logic as the official Docker client.
""" """
if 'DOCKER_CLIENT_TIMEOUT' in os.environ: if 'DOCKER_CLIENT_TIMEOUT' in environment:
log.warn("The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. " log.warn("The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. "
"Please use COMPOSE_HTTP_TIMEOUT instead.") "Please use COMPOSE_HTTP_TIMEOUT instead.")
try: try:
kwargs = kwargs_from_env(assert_hostname=False) kwargs = kwargs_from_env(assert_hostname=False, environment=environment)
except TLSParameterError: except TLSParameterError:
raise UserError( raise UserError(
"TLS configuration is invalid - make sure your DOCKER_TLS_VERIFY " "TLS configuration is invalid - make sure your DOCKER_TLS_VERIFY "
@ -67,6 +66,10 @@ def docker_client(version=None, tls_config=None, host=None):
if version: if version:
kwargs['version'] = version kwargs['version'] = version
kwargs['timeout'] = HTTP_TIMEOUT timeout = environment.get('COMPOSE_HTTP_TIMEOUT')
if timeout:
kwargs['timeout'] = int(timeout)
else:
kwargs['timeout'] = HTTP_TIMEOUT
return Client(**kwargs) return Client(**kwargs)

View File

@ -17,6 +17,7 @@ from .. import __version__
from ..config import config from ..config import config
from ..config import ConfigurationError from ..config import ConfigurationError
from ..config import parse_environment from ..config import parse_environment
from ..config.environment import Environment
from ..config.serialize import serialize_config from ..config.serialize import serialize_config
from ..const import DEFAULT_TIMEOUT from ..const import DEFAULT_TIMEOUT
from ..const import IS_WINDOWS_PLATFORM from ..const import IS_WINDOWS_PLATFORM
@ -222,8 +223,13 @@ class TopLevelCommand(object):
--services Print the service names, one per line. --services Print the service names, one per line.
""" """
config_path = get_config_path_from_options(config_options) environment = Environment.from_env_file(self.project_dir)
compose_config = config.load(config.find(self.project_dir, config_path)) config_path = get_config_path_from_options(
self.project_dir, config_options, environment
)
compose_config = config.load(
config.find(self.project_dir, config_path, environment)
)
if options['--quiet']: if options['--quiet']:
return return

View File

@ -2,6 +2,7 @@
from __future__ import absolute_import from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
from . import environment
from .config import ConfigurationError from .config import ConfigurationError
from .config import DOCKER_CONFIG_KEYS from .config import DOCKER_CONFIG_KEYS
from .config import find from .config import find

View File

@ -1,7 +1,6 @@
from __future__ import absolute_import from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
import codecs
import functools import functools
import logging import logging
import operator import operator
@ -17,6 +16,9 @@ from cached_property import cached_property
from ..const import COMPOSEFILE_V1 as V1 from ..const import COMPOSEFILE_V1 as V1
from ..const import COMPOSEFILE_V2_0 as V2_0 from ..const import COMPOSEFILE_V2_0 as V2_0
from ..utils import build_string_dict from ..utils import build_string_dict
from .environment import env_vars_from_file
from .environment import Environment
from .environment import split_env
from .errors import CircularReference from .errors import CircularReference
from .errors import ComposeFileNotFound from .errors import ComposeFileNotFound
from .errors import ConfigurationError from .errors import ConfigurationError
@ -113,13 +115,21 @@ DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml'
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files')): class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files environment')):
""" """
:param working_dir: the directory to use for relative paths in the config :param working_dir: the directory to use for relative paths in the config
:type working_dir: string :type working_dir: string
:param config_files: list of configuration files to load :param config_files: list of configuration files to load
:type config_files: list of :class:`ConfigFile` :type config_files: list of :class:`ConfigFile`
:param environment: computed environment values for this project
:type environment: :class:`environment.Environment`
""" """
def __new__(cls, working_dir, config_files, environment=None):
if environment is None:
environment = Environment.from_env_file(working_dir)
return super(ConfigDetails, cls).__new__(
cls, working_dir, config_files, environment
)
class ConfigFile(namedtuple('_ConfigFile', 'filename config')): class ConfigFile(namedtuple('_ConfigFile', 'filename config')):
@ -207,11 +217,13 @@ class ServiceConfig(namedtuple('_ServiceConfig', 'working_dir filename name conf
config) config)
def find(base_dir, filenames): def find(base_dir, filenames, environment):
if filenames == ['-']: if filenames == ['-']:
return ConfigDetails( return ConfigDetails(
os.getcwd(), os.getcwd(),
[ConfigFile(None, yaml.safe_load(sys.stdin))]) [ConfigFile(None, yaml.safe_load(sys.stdin))],
environment
)
if filenames: if filenames:
filenames = [os.path.join(base_dir, f) for f in filenames] filenames = [os.path.join(base_dir, f) for f in filenames]
@ -221,7 +233,9 @@ def find(base_dir, filenames):
log.debug("Using configuration files: {}".format(",".join(filenames))) log.debug("Using configuration files: {}".format(",".join(filenames)))
return ConfigDetails( return ConfigDetails(
os.path.dirname(filenames[0]), os.path.dirname(filenames[0]),
[ConfigFile.from_filename(f) for f in filenames]) [ConfigFile.from_filename(f) for f in filenames],
environment
)
def validate_config_version(config_files): def validate_config_version(config_files):
@ -289,7 +303,7 @@ def load(config_details):
validate_config_version(config_details.config_files) validate_config_version(config_details.config_files)
processed_files = [ processed_files = [
process_config_file(config_file) process_config_file(config_file, config_details.environment)
for config_file in config_details.config_files for config_file in config_details.config_files
] ]
config_details = config_details._replace(config_files=processed_files) config_details = config_details._replace(config_files=processed_files)
@ -301,10 +315,7 @@ def load(config_details):
networks = load_mapping( networks = load_mapping(
config_details.config_files, 'get_networks', 'Network' config_details.config_files, 'get_networks', 'Network'
) )
service_dicts = load_services( service_dicts = load_services(config_details, main_file)
config_details.working_dir,
main_file,
[file.get_service_dicts() for file in config_details.config_files])
if main_file.version != V1: if main_file.version != V1:
for service_dict in service_dicts: for service_dict in service_dicts:
@ -348,14 +359,16 @@ def load_mapping(config_files, get_func, entity_type):
return mapping return mapping
def load_services(working_dir, config_file, service_configs): def load_services(config_details, config_file):
def build_service(service_name, service_dict, service_names): def build_service(service_name, service_dict, service_names):
service_config = ServiceConfig.with_abs_paths( service_config = ServiceConfig.with_abs_paths(
working_dir, config_details.working_dir,
config_file.filename, config_file.filename,
service_name, service_name,
service_dict) service_dict)
resolver = ServiceExtendsResolver(service_config, config_file) resolver = ServiceExtendsResolver(
service_config, config_file, environment=config_details.environment
)
service_dict = process_service(resolver.run()) service_dict = process_service(resolver.run())
service_config = service_config._replace(config=service_dict) service_config = service_config._replace(config=service_dict)
@ -363,7 +376,8 @@ def load_services(working_dir, config_file, service_configs):
service_dict = finalize_service( service_dict = finalize_service(
service_config, service_config,
service_names, service_names,
config_file.version) config_file.version,
config_details.environment)
return service_dict return service_dict
def build_services(service_config): def build_services(service_config):
@ -383,6 +397,10 @@ def load_services(working_dir, config_file, service_configs):
for name in all_service_names for name in all_service_names
} }
service_configs = [
file.get_service_dicts() for file in config_details.config_files
]
service_config = service_configs[0] service_config = service_configs[0]
for next_config in service_configs[1:]: for next_config in service_configs[1:]:
service_config = merge_services(service_config, next_config) service_config = merge_services(service_config, next_config)
@ -390,16 +408,17 @@ def load_services(working_dir, config_file, service_configs):
return build_services(service_config) return build_services(service_config)
def interpolate_config_section(filename, config, section): def interpolate_config_section(filename, config, section, environment):
validate_config_section(filename, config, section) validate_config_section(filename, config, section)
return interpolate_environment_variables(config, section) return interpolate_environment_variables(config, section, environment)
def process_config_file(config_file, service_name=None): def process_config_file(config_file, environment, service_name=None):
services = interpolate_config_section( services = interpolate_config_section(
config_file.filename, config_file.filename,
config_file.get_service_dicts(), config_file.get_service_dicts(),
'service') 'service',
environment,)
if config_file.version == V2_0: if config_file.version == V2_0:
processed_config = dict(config_file.config) processed_config = dict(config_file.config)
@ -407,11 +426,13 @@ def process_config_file(config_file, service_name=None):
processed_config['volumes'] = interpolate_config_section( processed_config['volumes'] = interpolate_config_section(
config_file.filename, config_file.filename,
config_file.get_volumes(), config_file.get_volumes(),
'volume') 'volume',
environment,)
processed_config['networks'] = interpolate_config_section( processed_config['networks'] = interpolate_config_section(
config_file.filename, config_file.filename,
config_file.get_networks(), config_file.get_networks(),
'network') 'network',
environment,)
if config_file.version == V1: if config_file.version == V1:
processed_config = services processed_config = services
@ -428,11 +449,12 @@ def process_config_file(config_file, service_name=None):
class ServiceExtendsResolver(object): class ServiceExtendsResolver(object):
def __init__(self, service_config, config_file, already_seen=None): def __init__(self, service_config, config_file, environment, already_seen=None):
self.service_config = service_config self.service_config = service_config
self.working_dir = service_config.working_dir self.working_dir = service_config.working_dir
self.already_seen = already_seen or [] self.already_seen = already_seen or []
self.config_file = config_file self.config_file = config_file
self.environment = environment
@property @property
def signature(self): def signature(self):
@ -462,8 +484,8 @@ class ServiceExtendsResolver(object):
extends_file = ConfigFile.from_filename(config_path) extends_file = ConfigFile.from_filename(config_path)
validate_config_version([self.config_file, extends_file]) validate_config_version([self.config_file, extends_file])
extended_file = process_config_file( extended_file = process_config_file(
extends_file, extends_file, self.environment, service_name=service_name
service_name=service_name) )
service_config = extended_file.get_service(service_name) service_config = extended_file.get_service(service_name)
return config_path, service_config, service_name return config_path, service_config, service_name
@ -476,7 +498,9 @@ class ServiceExtendsResolver(object):
service_name, service_name,
service_dict), service_dict),
self.config_file, self.config_file,
already_seen=self.already_seen + [self.signature]) already_seen=self.already_seen + [self.signature],
environment=self.environment
)
service_config = resolver.run() service_config = resolver.run()
other_service_dict = process_service(service_config) other_service_dict = process_service(service_config)
@ -505,7 +529,7 @@ class ServiceExtendsResolver(object):
return filename return filename
def resolve_environment(service_dict): def resolve_environment(service_dict, environment=None):
"""Unpack any environment variables from an env_file, if set. """Unpack any environment variables from an env_file, if set.
Interpolate environment values if set. Interpolate environment values if set.
""" """
@ -514,12 +538,12 @@ def resolve_environment(service_dict):
env.update(env_vars_from_file(env_file)) env.update(env_vars_from_file(env_file))
env.update(parse_environment(service_dict.get('environment'))) env.update(parse_environment(service_dict.get('environment')))
return dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(env))
def resolve_build_args(build): def resolve_build_args(build, environment):
args = parse_build_arguments(build.get('args')) args = parse_build_arguments(build.get('args'))
return dict(resolve_env_var(k, v) for k, v in six.iteritems(args)) return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(args))
def validate_extended_service_dict(service_dict, filename, service): def validate_extended_service_dict(service_dict, filename, service):
@ -598,11 +622,11 @@ def process_service(service_config):
return service_dict return service_dict
def finalize_service(service_config, service_names, version): def finalize_service(service_config, service_names, version, environment):
service_dict = dict(service_config.config) service_dict = dict(service_config.config)
if 'environment' in service_dict or 'env_file' in service_dict: if 'environment' in service_dict or 'env_file' in service_dict:
service_dict['environment'] = resolve_environment(service_dict) service_dict['environment'] = resolve_environment(service_dict, environment)
service_dict.pop('env_file', None) service_dict.pop('env_file', None)
if 'volumes_from' in service_dict: if 'volumes_from' in service_dict:
@ -629,7 +653,7 @@ def finalize_service(service_config, service_names, version):
if 'restart' in service_dict: if 'restart' in service_dict:
service_dict['restart'] = parse_restart_spec(service_dict['restart']) service_dict['restart'] = parse_restart_spec(service_dict['restart'])
normalize_build(service_dict, service_config.working_dir) normalize_build(service_dict, service_config.working_dir, environment)
service_dict['name'] = service_config.name service_dict['name'] = service_config.name
return normalize_v1_service_format(service_dict) return normalize_v1_service_format(service_dict)
@ -777,15 +801,6 @@ def merge_environment(base, override):
return env return env
def split_env(env):
if isinstance(env, six.binary_type):
env = env.decode('utf-8', 'replace')
if '=' in env:
return env.split('=', 1)
else:
return env, None
def split_label(label): def split_label(label):
if '=' in label: if '=' in label:
return label.split('=', 1) return label.split('=', 1)
@ -823,30 +838,15 @@ def parse_ulimits(ulimits):
return dict(ulimits) return dict(ulimits)
def resolve_env_var(key, val): def resolve_env_var(key, val, environment):
if val is not None: if val is not None:
return key, val return key, val
elif key in os.environ: elif environment and key in environment:
return key, os.environ[key] return key, environment[key]
else: else:
return key, None return key, None
def env_vars_from_file(filename):
"""
Read in a line delimited file of environment variables.
"""
if not os.path.exists(filename):
raise ConfigurationError("Couldn't find env file: %s" % filename)
env = {}
for line in codecs.open(filename, 'r', 'utf-8'):
line = line.strip()
if line and not line.startswith('#'):
k, v = split_env(line)
env[k] = v
return env
def resolve_volume_paths(working_dir, service_dict): def resolve_volume_paths(working_dir, service_dict):
return [ return [
resolve_volume_path(working_dir, volume) resolve_volume_path(working_dir, volume)
@ -866,7 +866,7 @@ def resolve_volume_path(working_dir, volume):
return container_path return container_path
def normalize_build(service_dict, working_dir): def normalize_build(service_dict, working_dir, environment):
if 'build' in service_dict: if 'build' in service_dict:
build = {} build = {}
@ -876,7 +876,9 @@ def normalize_build(service_dict, working_dir):
else: else:
build.update(service_dict['build']) build.update(service_dict['build'])
if 'args' in build: if 'args' in build:
build['args'] = build_string_dict(resolve_build_args(build)) build['args'] = build_string_dict(
resolve_build_args(build, environment)
)
service_dict['build'] = build service_dict['build'] = build

View File

@ -0,0 +1,93 @@
from __future__ import absolute_import
from __future__ import unicode_literals
import codecs
import logging
import os
import six
from ..const import IS_WINDOWS_PLATFORM
from .errors import ConfigurationError
log = logging.getLogger(__name__)
def split_env(env):
if isinstance(env, six.binary_type):
env = env.decode('utf-8', 'replace')
if '=' in env:
return env.split('=', 1)
else:
return env, None
def env_vars_from_file(filename):
"""
Read in a line delimited file of environment variables.
"""
if not os.path.exists(filename):
raise ConfigurationError("Couldn't find env file: %s" % filename)
env = {}
for line in codecs.open(filename, 'r', 'utf-8'):
line = line.strip()
if line and not line.startswith('#'):
k, v = split_env(line)
env[k] = v
return env
class Environment(dict):
def __init__(self, *args, **kwargs):
super(Environment, self).__init__(*args, **kwargs)
self.missing_keys = []
@classmethod
def from_env_file(cls, base_dir):
def _initialize():
result = cls()
if base_dir is None:
return result
env_file_path = os.path.join(base_dir, '.env')
try:
return cls(env_vars_from_file(env_file_path))
except ConfigurationError:
pass
return result
instance = _initialize()
instance.update(os.environ)
return instance
def __getitem__(self, key):
try:
return super(Environment, self).__getitem__(key)
except KeyError:
if IS_WINDOWS_PLATFORM:
try:
return super(Environment, self).__getitem__(key.upper())
except KeyError:
pass
if key not in self.missing_keys:
log.warn(
"The {} variable is not set. Defaulting to a blank string."
.format(key)
)
self.missing_keys.append(key)
return ""
def __contains__(self, key):
result = super(Environment, self).__contains__(key)
if IS_WINDOWS_PLATFORM:
return (
result or super(Environment, self).__contains__(key.upper())
)
return result
def get(self, key, *args, **kwargs):
if IS_WINDOWS_PLATFORM:
return super(Environment, self).get(
key,
super(Environment, self).get(key.upper(), *args, **kwargs)
)
return super(Environment, self).get(key, *args, **kwargs)

View File

@ -2,7 +2,6 @@ from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
import logging import logging
import os
from string import Template from string import Template
import six import six
@ -11,12 +10,11 @@ from .errors import ConfigurationError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def interpolate_environment_variables(config, section): def interpolate_environment_variables(config, section, environment):
mapping = BlankDefaultDict(os.environ)
def process_item(name, config_dict): def process_item(name, config_dict):
return dict( return dict(
(key, interpolate_value(name, key, val, section, mapping)) (key, interpolate_value(name, key, val, section, environment))
for key, val in (config_dict or {}).items() for key, val in (config_dict or {}).items()
) )
@ -60,25 +58,6 @@ def interpolate(string, mapping):
raise InvalidInterpolation(string) raise InvalidInterpolation(string)
class BlankDefaultDict(dict):
def __init__(self, *args, **kwargs):
super(BlankDefaultDict, self).__init__(*args, **kwargs)
self.missing_keys = []
def __getitem__(self, key):
try:
return super(BlankDefaultDict, self).__getitem__(key)
except KeyError:
if key not in self.missing_keys:
log.warn(
"The {} variable is not set. Defaulting to a blank string."
.format(key)
)
self.missing_keys.append(key)
return ""
class InvalidInterpolation(Exception): class InvalidInterpolation(Exception):
def __init__(self, string): def __init__(self, string):
self.string = string self.string = string

View File

@ -5,7 +5,7 @@ import os
import sys import sys
DEFAULT_TIMEOUT = 10 DEFAULT_TIMEOUT = 10
HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) HTTP_TIMEOUT = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))
IMAGE_EVENTS = ['delete', 'import', 'pull', 'push', 'tag', 'untag'] IMAGE_EVENTS = ['delete', 'import', 'pull', 'push', 'tag', 'untag']
IS_WINDOWS_PLATFORM = (sys.platform == "win32") IS_WINDOWS_PLATFORM = (sys.platform == "win32")
LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number'

43
docs/env-file.md Normal file
View File

@ -0,0 +1,43 @@
<!--[metadata]>
+++
title = "Environment file"
description = "Declaring default environment variables in file"
keywords = ["fig, composition, compose, docker, orchestration, environment, env file"]
[menu.main]
parent = "workw_compose"
weight=10
+++
<![end-metadata]-->
# Environment file
Compose supports declaring default environment variables in an environment
file named `.env` and placed in the same folder as your
[compose file](compose-file.md).
Compose expects each line in an env file to be in `VAR=VAL` format. Lines
beginning with `#` (i.e. comments) are ignored, as are blank lines.
> Note: Values present in the environment at runtime will always override
> those defined inside the `.env` file. Similarly, values passed via
> command-line arguments take precedence as well.
Those environment variables will be used for
[variable substitution](compose-file.md#variable-substitution) in your Compose
file, but can also be used to define the following
[CLI variables](reference/envvars.md):
- `COMPOSE_API_VERSION`
- `COMPOSE_FILE`
- `COMPOSE_HTTP_TIMEOUT`
- `COMPOSE_PROJECT_NAME`
- `DOCKER_CERT_PATH`
- `DOCKER_HOST`
- `DOCKER_TLS_VERIFY`
## More Compose documentation
- [User guide](index.md)
- [Command line reference](./reference/index.md)
- [Compose file reference](compose-file.md)

View File

@ -23,6 +23,7 @@ Compose is a tool for defining and running multi-container Docker applications.
- [Frequently asked questions](faq.md) - [Frequently asked questions](faq.md)
- [Command line reference](./reference/index.md) - [Command line reference](./reference/index.md)
- [Compose file reference](compose-file.md) - [Compose file reference](compose-file.md)
- [Environment file](env-file.md)
To see a detailed list of changes for past and current releases of Docker To see a detailed list of changes for past and current releases of Docker
Compose, please refer to the Compose, please refer to the

View File

@ -17,6 +17,9 @@ Several environment variables are available for you to configure the Docker Comp
Variables starting with `DOCKER_` are the same as those used to configure the Variables starting with `DOCKER_` are the same as those used to configure the
Docker command-line client. If you're using `docker-machine`, then the `eval "$(docker-machine env my-docker-vm)"` command should set them to their correct values. (In this example, `my-docker-vm` is the name of a machine you created.) Docker command-line client. If you're using `docker-machine`, then the `eval "$(docker-machine env my-docker-vm)"` command should set them to their correct values. (In this example, `my-docker-vm` is the name of a machine you created.)
> Note: Some of these variables can also be provided using an
> [environment file](../env-file.md)
## COMPOSE\_PROJECT\_NAME ## COMPOSE\_PROJECT\_NAME
Sets the project name. This value is prepended along with the service name to the container container on start up. For example, if you project name is `myapp` and it includes two services `db` and `web` then compose starts containers named `myapp_db_1` and `myapp_web_1` respectively. Sets the project name. This value is prepended along with the service name to the container container on start up. For example, if you project name is `myapp` and it includes two services `db` and `web` then compose starts containers named `myapp_db_1` and `myapp_web_1` respectively.
@ -81,3 +84,4 @@ it failed. Defaults to 60 seconds.
- [User guide](../index.md) - [User guide](../index.md)
- [Installing Compose](../install.md) - [Installing Compose](../install.md)
- [Compose file reference](../compose-file.md) - [Compose file reference](../compose-file.md)
- [Environment file](../env-file.md)

4
tests/fixtures/default-env-file/.env vendored Normal file
View File

@ -0,0 +1,4 @@
IMAGE=alpine:latest
COMMAND=true
PORT1=5643
PORT2=9999

View File

@ -0,0 +1,6 @@
web:
image: ${IMAGE}
command: ${COMMAND}
ports:
- $PORT1
- $PORT2

View File

@ -13,4 +13,5 @@ def build_config(contents, **kwargs):
def build_config_details(contents, working_dir='working_dir', filename='filename.yml'): def build_config_details(contents, working_dir='working_dir', filename='filename.yml'):
return ConfigDetails( return ConfigDetails(
working_dir, working_dir,
[ConfigFile(filename, contents)]) [ConfigFile(filename, contents)],
)

View File

@ -12,6 +12,7 @@ from compose.cli.docker_client import docker_client
from compose.config.config import resolve_environment from compose.config.config import resolve_environment
from compose.config.config import V1 from compose.config.config import V1
from compose.config.config import V2_0 from compose.config.config import V2_0
from compose.config.environment import Environment
from compose.const import API_VERSIONS from compose.const import API_VERSIONS
from compose.const import LABEL_PROJECT from compose.const import LABEL_PROJECT
from compose.progress_stream import stream_output from compose.progress_stream import stream_output
@ -60,7 +61,7 @@ class DockerClientTestCase(unittest.TestCase):
else: else:
version = API_VERSIONS[V2_0] version = API_VERSIONS[V2_0]
cls.client = docker_client(version) cls.client = docker_client(Environment(), version)
def tearDown(self): def tearDown(self):
for c in self.client.containers( for c in self.client.containers(
@ -89,7 +90,9 @@ class DockerClientTestCase(unittest.TestCase):
if 'command' not in kwargs: if 'command' not in kwargs:
kwargs['command'] = ["top"] kwargs['command'] = ["top"]
kwargs['environment'] = resolve_environment(kwargs) kwargs['environment'] = resolve_environment(
kwargs, Environment.from_env_file(None)
)
labels = dict(kwargs.setdefault('labels', {})) labels = dict(kwargs.setdefault('labels', {}))
labels['com.docker.compose.test-name'] = self.id() labels['com.docker.compose.test-name'] = self.id()

View File

@ -6,6 +6,7 @@ import os
import pytest import pytest
from compose.cli.command import get_config_path_from_options from compose.cli.command import get_config_path_from_options
from compose.config.environment import Environment
from compose.const import IS_WINDOWS_PLATFORM from compose.const import IS_WINDOWS_PLATFORM
from tests import mock from tests import mock
@ -15,24 +16,33 @@ class TestGetConfigPathFromOptions(object):
def test_path_from_options(self): def test_path_from_options(self):
paths = ['one.yml', 'two.yml'] paths = ['one.yml', 'two.yml']
opts = {'--file': paths} opts = {'--file': paths}
assert get_config_path_from_options(opts) == paths environment = Environment.from_env_file('.')
assert get_config_path_from_options('.', opts, environment) == paths
def test_single_path_from_env(self): def test_single_path_from_env(self):
with mock.patch.dict(os.environ): with mock.patch.dict(os.environ):
os.environ['COMPOSE_FILE'] = 'one.yml' os.environ['COMPOSE_FILE'] = 'one.yml'
assert get_config_path_from_options({}) == ['one.yml'] environment = Environment.from_env_file('.')
assert get_config_path_from_options('.', {}, environment) == ['one.yml']
@pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix separator') @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix separator')
def test_multiple_path_from_env(self): def test_multiple_path_from_env(self):
with mock.patch.dict(os.environ): with mock.patch.dict(os.environ):
os.environ['COMPOSE_FILE'] = 'one.yml:two.yml' os.environ['COMPOSE_FILE'] = 'one.yml:two.yml'
assert get_config_path_from_options({}) == ['one.yml', 'two.yml'] environment = Environment.from_env_file('.')
assert get_config_path_from_options(
'.', {}, environment
) == ['one.yml', 'two.yml']
@pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows separator') @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows separator')
def test_multiple_path_from_env_windows(self): def test_multiple_path_from_env_windows(self):
with mock.patch.dict(os.environ): with mock.patch.dict(os.environ):
os.environ['COMPOSE_FILE'] = 'one.yml;two.yml' os.environ['COMPOSE_FILE'] = 'one.yml;two.yml'
assert get_config_path_from_options({}) == ['one.yml', 'two.yml'] environment = Environment.from_env_file('.')
assert get_config_path_from_options(
'.', {}, environment
) == ['one.yml', 'two.yml']
def test_no_path(self): def test_no_path(self):
assert not get_config_path_from_options({}) environment = Environment.from_env_file('.')
assert not get_config_path_from_options('.', {}, environment)

View File

@ -17,12 +17,12 @@ class DockerClientTestCase(unittest.TestCase):
def test_docker_client_no_home(self): def test_docker_client_no_home(self):
with mock.patch.dict(os.environ): with mock.patch.dict(os.environ):
del os.environ['HOME'] del os.environ['HOME']
docker_client() docker_client(os.environ)
def test_docker_client_with_custom_timeout(self): def test_docker_client_with_custom_timeout(self):
timeout = 300 timeout = 300
with mock.patch('compose.cli.docker_client.HTTP_TIMEOUT', 300): with mock.patch('compose.cli.docker_client.HTTP_TIMEOUT', 300):
client = docker_client() client = docker_client(os.environ)
self.assertEqual(client.timeout, int(timeout)) self.assertEqual(client.timeout, int(timeout))

View File

@ -3,6 +3,8 @@ from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
import shutil
import tempfile
import docker import docker
import py import py
@ -43,11 +45,11 @@ class CLITestCase(unittest.TestCase):
project_name = get_project_name(None, project_name=name) project_name = get_project_name(None, project_name=name)
self.assertEquals('explicitprojectname', project_name) self.assertEquals('explicitprojectname', project_name)
@mock.patch.dict(os.environ)
def test_project_name_from_environment_new_var(self): def test_project_name_from_environment_new_var(self):
name = 'namefromenv' name = 'namefromenv'
with mock.patch.dict(os.environ): os.environ['COMPOSE_PROJECT_NAME'] = name
os.environ['COMPOSE_PROJECT_NAME'] = name project_name = get_project_name(None)
project_name = get_project_name(None)
self.assertEquals(project_name, name) self.assertEquals(project_name, name)
def test_project_name_with_empty_environment_var(self): def test_project_name_with_empty_environment_var(self):
@ -57,6 +59,22 @@ class CLITestCase(unittest.TestCase):
project_name = get_project_name(base_dir) project_name = get_project_name(base_dir)
self.assertEquals('simplecomposefile', project_name) self.assertEquals('simplecomposefile', project_name)
@mock.patch.dict(os.environ)
def test_project_name_with_environment_file(self):
base_dir = tempfile.mkdtemp()
try:
name = 'namefromenvfile'
with open(os.path.join(base_dir, '.env'), 'w') as f:
f.write('COMPOSE_PROJECT_NAME={}'.format(name))
project_name = get_project_name(base_dir)
assert project_name == name
# Environment has priority over .env file
os.environ['COMPOSE_PROJECT_NAME'] = 'namefromenv'
assert get_project_name(base_dir) == os.environ['COMPOSE_PROJECT_NAME']
finally:
shutil.rmtree(base_dir)
def test_get_project(self): def test_get_project(self):
base_dir = 'tests/fixtures/longer-filename-composefile' base_dir = 'tests/fixtures/longer-filename-composefile'
project = get_project(base_dir) project = get_project(base_dir)

View File

@ -17,6 +17,7 @@ from compose.config.config import resolve_build_args
from compose.config.config import resolve_environment from compose.config.config import resolve_environment
from compose.config.config import V1 from compose.config.config import V1
from compose.config.config import V2_0 from compose.config.config import V2_0
from compose.config.environment import Environment
from compose.config.errors import ConfigurationError from compose.config.errors import ConfigurationError
from compose.config.errors import VERSION_EXPLANATION from compose.config.errors import VERSION_EXPLANATION
from compose.config.types import VolumeSpec from compose.config.types import VolumeSpec
@ -36,7 +37,9 @@ def make_service_dict(name, service_dict, working_dir, filename=None):
filename=filename, filename=filename,
name=name, name=name,
config=service_dict), config=service_dict),
config.ConfigFile(filename=filename, config={})) config.ConfigFile(filename=filename, config={}),
environment=Environment.from_env_file(working_dir)
)
return config.process_service(resolver.run()) return config.process_service(resolver.run())
@ -1581,8 +1584,25 @@ class PortsTest(unittest.TestCase):
class InterpolationTest(unittest.TestCase): class InterpolationTest(unittest.TestCase):
@mock.patch.dict(os.environ)
def test_config_file_with_environment_file(self):
project_dir = 'tests/fixtures/default-env-file'
service_dicts = config.load(
config.find(
project_dir, None, Environment.from_env_file(project_dir)
)
).services
self.assertEqual(service_dicts[0], {
'name': 'web',
'image': 'alpine:latest',
'ports': ['5643', '9999'],
'command': 'true'
})
@mock.patch.dict(os.environ) @mock.patch.dict(os.environ)
def test_config_file_with_environment_variable(self): def test_config_file_with_environment_variable(self):
project_dir = 'tests/fixtures/environment-interpolation'
os.environ.update( os.environ.update(
IMAGE="busybox", IMAGE="busybox",
HOST_PORT="80", HOST_PORT="80",
@ -1590,7 +1610,9 @@ class InterpolationTest(unittest.TestCase):
) )
service_dicts = config.load( service_dicts = config.load(
config.find('tests/fixtures/environment-interpolation', None), config.find(
project_dir, None, Environment.from_env_file(project_dir)
)
).services ).services
self.assertEqual(service_dicts, [ self.assertEqual(service_dicts, [
@ -1620,7 +1642,7 @@ class InterpolationTest(unittest.TestCase):
None, None,
) )
with mock.patch('compose.config.interpolation.log') as log: with mock.patch('compose.config.environment.log') as log:
config.load(config_details) config.load(config_details)
self.assertEqual(2, log.warn.call_count) self.assertEqual(2, log.warn.call_count)
@ -2041,7 +2063,9 @@ class EnvTest(unittest.TestCase):
}, },
} }
self.assertEqual( self.assertEqual(
resolve_environment(service_dict), resolve_environment(
service_dict, Environment.from_env_file(None)
),
{'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None}, {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None},
) )
@ -2078,7 +2102,10 @@ class EnvTest(unittest.TestCase):
os.environ['FILE_DEF_EMPTY'] = 'E2' os.environ['FILE_DEF_EMPTY'] = 'E2'
os.environ['ENV_DEF'] = 'E3' os.environ['ENV_DEF'] = 'E3'
self.assertEqual( self.assertEqual(
resolve_environment({'env_file': ['tests/fixtures/env/resolve.env']}), resolve_environment(
{'env_file': ['tests/fixtures/env/resolve.env']},
Environment.from_env_file(None)
),
{ {
'FILE_DEF': u'bär', 'FILE_DEF': u'bär',
'FILE_DEF_EMPTY': '', 'FILE_DEF_EMPTY': '',
@ -2101,7 +2128,7 @@ class EnvTest(unittest.TestCase):
} }
} }
self.assertEqual( self.assertEqual(
resolve_build_args(build), resolve_build_args(build, Environment.from_env_file(build['context'])),
{'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None}, {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None},
) )
@ -2133,7 +2160,9 @@ class EnvTest(unittest.TestCase):
def load_from_filename(filename): def load_from_filename(filename):
return config.load(config.find('.', [filename])).services return config.load(
config.find('.', [filename], Environment.from_env_file('.'))
).services
class ExtendsTest(unittest.TestCase): class ExtendsTest(unittest.TestCase):
@ -2465,6 +2494,7 @@ class ExtendsTest(unittest.TestCase):
}, },
])) ]))
@mock.patch.dict(os.environ)
def test_extends_with_environment_and_env_files(self): def test_extends_with_environment_and_env_files(self):
tmpdir = py.test.ensuretemp('test_extends_with_environment') tmpdir = py.test.ensuretemp('test_extends_with_environment')
self.addCleanup(tmpdir.remove) self.addCleanup(tmpdir.remove)
@ -2520,12 +2550,12 @@ class ExtendsTest(unittest.TestCase):
}, },
}, },
] ]
with mock.patch.dict(os.environ):
os.environ['SECRET'] = 'secret' os.environ['SECRET'] = 'secret'
os.environ['THING'] = 'thing' os.environ['THING'] = 'thing'
os.environ['COMMON_ENV_FILE'] = 'secret' os.environ['COMMON_ENV_FILE'] = 'secret'
os.environ['TOP_ENV_FILE'] = 'secret' os.environ['TOP_ENV_FILE'] = 'secret'
config = load_from_filename(str(tmpdir.join('docker-compose.yml'))) config = load_from_filename(str(tmpdir.join('docker-compose.yml')))
assert config == expected assert config == expected

View File

@ -6,6 +6,7 @@ import os
import mock import mock
import pytest import pytest
from compose.config.environment import Environment
from compose.config.interpolation import interpolate_environment_variables from compose.config.interpolation import interpolate_environment_variables
@ -19,7 +20,7 @@ def mock_env():
def test_interpolate_environment_variables_in_services(mock_env): def test_interpolate_environment_variables_in_services(mock_env):
services = { services = {
'servivea': { 'servicea': {
'image': 'example:${USER}', 'image': 'example:${USER}',
'volumes': ['$FOO:/target'], 'volumes': ['$FOO:/target'],
'logging': { 'logging': {
@ -31,7 +32,7 @@ def test_interpolate_environment_variables_in_services(mock_env):
} }
} }
expected = { expected = {
'servivea': { 'servicea': {
'image': 'example:jenny', 'image': 'example:jenny',
'volumes': ['bar:/target'], 'volumes': ['bar:/target'],
'logging': { 'logging': {
@ -42,7 +43,9 @@ def test_interpolate_environment_variables_in_services(mock_env):
} }
} }
} }
assert interpolate_environment_variables(services, 'service') == expected assert interpolate_environment_variables(
services, 'service', Environment.from_env_file(None)
) == expected
def test_interpolate_environment_variables_in_volumes(mock_env): def test_interpolate_environment_variables_in_volumes(mock_env):
@ -66,4 +69,6 @@ def test_interpolate_environment_variables_in_volumes(mock_env):
}, },
'other': {}, 'other': {},
} }
assert interpolate_environment_variables(volumes, 'volume') == expected assert interpolate_environment_variables(
volumes, 'volume', Environment.from_env_file(None)
) == expected

View File

@ -3,7 +3,7 @@ from __future__ import unicode_literals
import unittest import unittest
from compose.config.interpolation import BlankDefaultDict as bddict from compose.config.environment import Environment as bddict
from compose.config.interpolation import interpolate from compose.config.interpolation import interpolate
from compose.config.interpolation import InvalidInterpolation from compose.config.interpolation import InvalidInterpolation