From 8538ff56716c09196239b324eea55607d82eed8f Mon Sep 17 00:00:00 2001 From: Luna Das Date: Thu, 12 Apr 2018 17:51:22 +0530 Subject: [PATCH] Add no oauth middleware to bypass keystone authentication This PS adds noauth middleware to bypass keystone authentication which will occur when Deckhand's server is executed in development mode. Development mode is enabled by setting development_mode as True in etc/deckhand/deckhand.conf.sample. The logic is similar to Drydock's here: [0]. [0] https://github.com/att-comdev/drydock/blob/1c78477e957bcac857a30140d40f8e2d718a5f5b/drydock_provisioner/util.py#L43 Co-Authored-By: Luna Das Co-Authored-By: Felipe Monteiro Change-Id: I677d3d92768e0aa1a550772700403e0f028b0c59 --- deckhand/conf/config.py | 20 ++--- deckhand/control/api.py | 42 +++++++--- deckhand/control/middleware.py | 2 +- deckhand/control/no_oauth_middleware.py | 63 +++++++++++++++ deckhand/tests/deckhand.conf.test | 5 +- deckhand/tests/unit/base.py | 2 + deckhand/tests/unit/control/base.py | 4 +- .../unit/control/test_api_initialization.py | 10 ++- docs/source/getting-started.rst | 27 ++++++- etc/deckhand/deckhand.conf.sample | 79 +++++++++++++++---- etc/deckhand/noauth-paste.ini | 35 ++++++++ .../development-mode-51208c39e9eee34f.yaml | 10 +++ tools/common-tests.sh | 63 +++++++++------ tools/functional-tests.sh | 16 ++-- tools/integration-tests.sh | 2 +- tox.ini | 18 +++-- 16 files changed, 311 insertions(+), 87 deletions(-) create mode 100644 deckhand/control/no_oauth_middleware.py create mode 100644 etc/deckhand/noauth-paste.ini create mode 100644 releasenotes/notes/development-mode-51208c39e9eee34f.yaml diff --git a/deckhand/conf/config.py b/deckhand/conf/config.py index 57d33641..4dae85db 100644 --- a/deckhand/conf/config.py +++ b/deckhand/conf/config.py @@ -36,22 +36,12 @@ barbican_opts = [ default_opts = [ - cfg.BoolOpt('allow_anonymous_access', default=False, - help=""" -Allow limited access to unauthenticated users. - -Assign a boolean to determine API access for unauthenticated -users. When set to False, the API cannot be accessed by -unauthenticated users. When set to True, unauthenticated users can -access the API with read-only privileges. - -Possible values: - * True - * False -"""), cfg.BoolOpt('profiler', default=False, - help="Enabling profiling of API requests. Do NOT " - "use in production."), + help="Enables profiling of API requests. Do NOT use in " + "production."), + cfg.BoolOpt('development_mode', default=False, + help="Enables development mode, which disables Keystone " + "authentication. Do NOT use in production.") ] diff --git a/deckhand/control/api.py b/deckhand/control/api.py index b58daba0..3a4fd699 100644 --- a/deckhand/control/api.py +++ b/deckhand/control/api.py @@ -27,22 +27,43 @@ CONF = cfg.CONF logging.register_options(CONF) LOG = logging.getLogger(__name__) -CONFIG_FILES = ['deckhand.conf', 'deckhand-paste.ini'] +CONFIG_FILES = { + 'conf': 'deckhand.conf', + 'paste': 'deckhand-paste.ini' +} +_NO_AUTH_CONFIG = 'noauth-paste.ini' def _get_config_files(env=None): if env is None: env = os.environ + + config_files = CONFIG_FILES.copy() dirname = env.get('DECKHAND_CONFIG_DIR', '/etc/deckhand').strip() - return [os.path.join(dirname, config_file) for config_file in CONFIG_FILES] + + # Workaround the fact that this reads from a config file to determine which + # paste.ini file to use for server instantiation. This chicken and egg + # problem is solved by using ConfigParser below. + conf_path = os.path.join(dirname, config_files['conf']) + temp_conf = {} + config_parser = cfg.ConfigParser(conf_path, temp_conf) + config_parser.parse() + use_development_mode = ( + temp_conf['DEFAULT'].get('development_mode') == ['true'] + ) + + if use_development_mode: + config_files['paste'] = _NO_AUTH_CONFIG + LOG.warning('Development mode enabled - Keystone authentication ' + 'disabled.') + + return { + key: os.path.join(dirname, file) for key, file in config_files.items() + } def setup_logging(conf): - # Add additional dependent libraries that have unhelp bug levels - extra_log_level_defaults = [] - - logging.set_defaults(default_log_levels=logging.get_default_log_levels() + - extra_log_level_defaults) + logging.set_defaults(default_log_levels=logging.get_default_log_levels()) logging.setup(conf, 'deckhand') py_logging.captureWarnings(True) @@ -53,9 +74,12 @@ def init_application(): Create routes for the v1.0 API and sets up logging. """ config_files = _get_config_files() - paste_file = config_files[-1] + paste_file = config_files['paste'] + + CONF([], + project='deckhand', + default_config_files=list(config_files.values())) - CONF([], project='deckhand', default_config_files=config_files) setup_logging(CONF) policy.Enforcer(CONF) diff --git a/deckhand/control/middleware.py b/deckhand/control/middleware.py index dd8189ce..2d308bb8 100644 --- a/deckhand/control/middleware.py +++ b/deckhand/control/middleware.py @@ -54,7 +54,7 @@ class ContextMiddleware(object): if req.headers.get('X-IDENTITY-STATUS') == 'Confirmed': req.context = deckhand.context.RequestContext.from_environ( req.env) - elif CONF.allow_anonymous_access: + elif CONF.development_mode: req.context = deckhand.context.get_context() else: raise falcon.HTTPUnauthorized() diff --git a/deckhand/control/no_oauth_middleware.py b/deckhand/control/no_oauth_middleware.py new file mode 100644 index 00000000..6e438a3d --- /dev/null +++ b/deckhand/control/no_oauth_middleware.py @@ -0,0 +1,63 @@ +# Copyright 2018 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class NoAuthFilter(object): + """PasteDeploy filter for NoAuth to be used in testing.""" + + def __init__(self, app, forged_roles=None): + self.app = app + self.forged_roles = forged_roles or ('admin',) + + def __call__(self, environ, start_response): + """Forge headers to make unauthenticated requests look authenticated. + + If the request has a X-AUTH-TOKEN header, + assume it is a valid request and + noop. Otherwise forge Keystone middleware headers so the + request looks valid + with the configured forged roles. + """ + if 'HTTP_X_AUTH_TOKEN' in environ: + return self.app(environ, start_response) + + environ['HTTP_X_IDENTITY_STATUS'] = 'Confirmed' + + for envvar in [ + 'USER_NAME', 'USER_ID', 'USER_DOMAIN_ID', 'PROJECT_ID', + 'PROJECT_DOMAIN_NAME' + ]: + varname = "HTTP_X_%s" % envvar + environ[varname] = 'noauth' + + if 'admin' in self.forged_roles: + environ['HTTP_X_IS_ADMIN_PROJECT'] = 'True' + else: + environ['HTTP_X_IS_ADMIN_PROJECT'] = 'False' + environ['HTTP_X_ROLES'] = ','.join(self.forged_roles) + + return self.app(environ, start_response) + + +def noauth_filter_factory(global_conf, forged_roles): + """Create a NoAuth paste deploy filter + + :param forged_roles: A space seperated list for roles to forge on requests + """ + forged_roles = forged_roles.split() + + def filter(app): + return NoAuthFilter(app, forged_roles) + + return filter diff --git a/deckhand/tests/deckhand.conf.test b/deckhand/tests/deckhand.conf.test index fd0ae445..36883a74 100644 --- a/deckhand/tests/deckhand.conf.test +++ b/deckhand/tests/deckhand.conf.test @@ -2,10 +2,7 @@ debug = true publish_errors = true use_stderr = true -# NOTE: allow_anonymous_access allows these functional tests to get around -# Keystone authentication, but the context that is provided has zero privileges -# so we must also override the policy file for authorization to pass. -allow_anonymous_access = true +development_mode = false [oslo_policy] policy_file = policy.yaml diff --git a/deckhand/tests/unit/base.py b/deckhand/tests/unit/base.py index 980aeaa3..c3fd3bc9 100644 --- a/deckhand/tests/unit/base.py +++ b/deckhand/tests/unit/base.py @@ -38,6 +38,8 @@ class DeckhandTestCase(testtools.TestCase): self.useFixture(fixtures.FakeLogger('deckhand')) self.useFixture(dh_fixtures.ConfPatcher( api_endpoint='http://127.0.0.1/key-manager', group='barbican')) + self.useFixture(dh_fixtures.ConfPatcher( + development_mode=True, group=None)) def override_config(self, name, override, group=None): CONF.set_override(name, override, group) diff --git a/deckhand/tests/unit/control/base.py b/deckhand/tests/unit/control/base.py index 0daa0eff..34dc7448 100644 --- a/deckhand/tests/unit/control/base.py +++ b/deckhand/tests/unit/control/base.py @@ -31,9 +31,9 @@ class BaseControllerTest(test_base.DeckhandWithDBTestCase, self.app = falcon_testing.TestClient( service.deckhand_app_factory(None)) self.policy = self.useFixture(fixtures.RealPolicyFixture()) - # NOTE: allow_anonymous_access allows these unit tests to get around + # NOTE: development_mode allows these unit tests to get around # Keystone authentication. - self.useFixture(fixtures.ConfPatcher(allow_anonymous_access=True)) + self.useFixture(fixtures.ConfPatcher(development_mode=True)) def _read_data(self, file_name): # Reads data from a file in the resources directory diff --git a/deckhand/tests/unit/control/test_api_initialization.py b/deckhand/tests/unit/control/test_api_initialization.py index 4e399c5e..a9ebc832 100644 --- a/deckhand/tests/unit/control/test_api_initialization.py +++ b/deckhand/tests/unit/control/test_api_initialization.py @@ -49,10 +49,12 @@ class TestApi(test_base.DeckhandTestCase): curr_path = os.path.dirname(os.path.realpath(__file__)) repo_path = os.path.join( curr_path, os.pardir, os.pardir, os.pardir, os.pardir) - temp_config_files = [ - os.path.join(repo_path, 'etc', 'deckhand', 'deckhand.conf.sample'), - os.path.join(repo_path, 'etc', 'deckhand', 'deckhand-paste.ini') - ] + temp_config_files = { + 'conf': os.path.join( + repo_path, 'etc', 'deckhand', 'deckhand.conf.sample'), + 'paste': os.path.join( + repo_path, 'etc', 'deckhand', 'deckhand-paste.ini') + } mock_get_config_files = self.patchobject( api, '_get_config_files', autospec=True) mock_get_config_files.return_value = temp_config_files diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index c74a63f9..271ab34b 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -175,12 +175,37 @@ Substitute the connection information into the config file in Finally, run Deckhand:: $ chmod +x entrypoint.sh - $ ./entrypoint.sh + $ ./entrypoint.sh server To kill the ephemeral DB afterward:: $ pifpaf_stop +Development Mode +---------------- + +Development mode means running Deckhand without Keystone authentication. +Note that enabling development mode will effectively disable all authN +and authZ in Deckhand. + +To enable development mode, add the following to the ``deckhand.conf`` +inside ``$CONF_DIR``: + +.. code-block:: ini + + [DEFAULT] + development_mode = True + +After, from the command line, execute: + +.. code-block:: console + + $ [sudo] docker run --rm \ + --net=host \ + -p 9000:9000 \ + -v $CONF_DIR:/etc/deckhand \ + quay.io/attcomdev/deckhand:latest server + Development Utilities --------------------- diff --git a/etc/deckhand/deckhand.conf.sample b/etc/deckhand/deckhand.conf.sample index ecbbaab5..453c97fb 100644 --- a/etc/deckhand/deckhand.conf.sample +++ b/etc/deckhand/deckhand.conf.sample @@ -4,20 +4,12 @@ # From deckhand.conf # -# -# Allow limited access to unauthenticated users. -# -# Assign a boolean to determine API access for unathenticated -# users. When set to False, the API cannot be accessed by -# unauthenticated users. When set to True, unauthenticated users can -# access the API with read-only privileges. This however only applies -# when using ContextMiddleware. -# -# Possible values: -# * True -# * False -# (boolean value) -#allow_anonymous_access = false +# Enables profiling of API requests. Do NOT use in production. (boolean value) +#profiler = false + +# Enables development mode, which disables Keystone authentication. Do NOT use +# in production. (boolean value) +#development_mode = false # # From oslo.log @@ -76,6 +68,10 @@ # log_config_append is set. (string value) #syslog_log_facility = LOG_USER +# Use JSON formatting for logging. This option is ignored if log_config_append +# is set. (boolean value) +#use_json = false + # Log output to standard error. This option is ignored if log_config_append is # set. (boolean value) #use_stderr = false @@ -165,6 +161,9 @@ # Authentication URL (string value) #auth_url = +# Scope for system operations (string value) +#system_scope = + # Domain ID to scope to (string value) #domain_id = @@ -337,6 +336,10 @@ # raised. Set to -1 to specify an infinite retry count. (integer value) #db_max_retries = 20 +# Optional URL parameters to append onto the connection URL at connect time; +# specify as param1=value1¶m2=value2&... (string value) +#connection_parameters = + [healthcheck] @@ -379,6 +382,22 @@ # you're using a versioned v2 endpoint here, then this should *not* be the same # endpoint the service user utilizes for validating tokens, because normal end # users may not be able to reach that endpoint. (string value) +# Deprecated group/name - [keystone_authtoken]/auth_uri +#www_authenticate_uri = + +# DEPRECATED: Complete "public" Identity API endpoint. This endpoint should not +# be an "admin" endpoint, as it should be accessible by all end users. +# Unauthenticated clients are redirected to this endpoint to authenticate. +# Although this endpoint should ideally be unversioned, client support in the +# wild varies. If you're using a versioned v2 endpoint here, then this should +# *not* be the same endpoint the service user utilizes for validating tokens, +# because normal end users may not be able to reach that endpoint. This option +# is deprecated in favor of www_authenticate_uri and will be removed in the S +# release. (string value) +# This option is deprecated for removal since Queens. +# Its value may be silently ignored in the future. +# Reason: The auth_uri option is deprecated in favor of www_authenticate_uri and +# will be removed in the S release. #auth_uri = # API version of the admin Identity API endpoint. (string value) @@ -451,7 +470,10 @@ # in the cache. If ENCRYPT, token data is encrypted and authenticated in the # cache. If the value is not one of these options or empty, auth_token will # raise an exception on initialization. (string value) -# Allowed values: None, MAC, ENCRYPT +# Possible values: +# None - +# MAC - +# ENCRYPT - #memcache_security_strategy = None # (Optional, mandatory if memcache_security_strategy is defined) This string is @@ -568,6 +590,14 @@ # From oslo.policy # +# This option controls whether or not to enforce scope when evaluating policies. +# If ``True``, the scope of the token used in the request is compared to the +# ``scope_types`` of the policy being enforced. If the scopes do not match, an +# ``InvalidScope`` exception will be raised. If ``False``, a message will be +# logged informing operators that policies are being invoked with mismatching +# scope. (boolean value) +#enforce_scope = false + # The file that defines policies. (string value) #policy_file = policy.json @@ -580,3 +610,22 @@ # directories to be searched. Missing or empty directories are ignored. (multi # valued) #policy_dirs = policy.d + +# Content Type to send and receive data for REST based policy check (string +# value) +# Possible values: +# application/x-www-form-urlencoded - +# application/json - +#remote_content_type = application/x-www-form-urlencoded + +# server identity verification for REST based policy check (boolean value) +#remote_ssl_verify_server_crt = false + +# Absolute path to ca cert file for REST based policy check (string value) +#remote_ssl_ca_crt_file = + +# Absolute path to client cert for REST based policy check (string value) +#remote_ssl_client_crt_file = + +# Absolute path client key file REST based policy check (string value) +#remote_ssl_client_key_file = diff --git a/etc/deckhand/noauth-paste.ini b/etc/deckhand/noauth-paste.ini new file mode 100644 index 00000000..9208c45b --- /dev/null +++ b/etc/deckhand/noauth-paste.ini @@ -0,0 +1,35 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# PasteDeploy configuration file without Keystone authentication. + +[app:api] +paste.app_factory = deckhand.service:deckhand_app_factory + +[filter:noauth] +forged_roles = admin +paste.filter_factory = deckhand.control.no_oauth_middleware:noauth_filter_factory + +[filter:debug] +use = egg:oslo.middleware#debug + +[filter:cors] +paste.filter_factory = oslo_middleware.cors:filter_factory +oslo_config_project = deckhand + +[filter:request_id] +paste.filter_factory = oslo_middleware:RequestId.factory + +[pipeline:deckhand_api] +pipeline = noauth api diff --git a/releasenotes/notes/development-mode-51208c39e9eee34f.yaml b/releasenotes/notes/development-mode-51208c39e9eee34f.yaml new file mode 100644 index 00000000..1e824ad0 --- /dev/null +++ b/releasenotes/notes/development-mode-51208c39e9eee34f.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Development mode has been added to Deckhand, allowing for the possibility + of running Deckhand without Keystone. A new paste file has been added + to ``etc/deckhand`` called ``noauth-paste.ini`` which excludes Keystone + authentication. To run Deckhand in development mode, set development_mode + as True in deckhand.conf. Note that Deckhand will expect to find + ``noauth-paste.ini`` on the host with development_mode set as True in + etc/deckhand/deckhand.conf.sample. diff --git a/tools/common-tests.sh b/tools/common-tests.sh index 6b8df845..df766078 100644 --- a/tools/common-tests.sh +++ b/tools/common-tests.sh @@ -10,6 +10,16 @@ function log_section { function deploy_postgre { + ####################################### + # Deploy an ephemeral PostgreSQL DB. + # Globals: + # POSTGRES_ID + # POSTGRES_IP + # Arguments: + # None + # Returns: + # None + ####################################### set -xe POSTGRES_ID=$( @@ -31,6 +41,19 @@ function deploy_postgre { function gen_config { + ####################################### + # Generate sample configuration file + # Globals: + # CONF_DIR + # DECKHAND_TEST_URL + # DATABASE_URL + # DECKHAND_CONFIG_DIR + # Arguments: + # disable_keystone: true or false + # Deckhand test URL: URL to Deckhand wsgi server + # Returns: + # None + ####################################### set -xe log_section "Creating config directory and test deckhand.conf" @@ -38,7 +61,8 @@ function gen_config { CONF_DIR=$(mktemp -d -p $(pwd)) sudo chmod 777 -R $CONF_DIR - export DECKHAND_TEST_URL=$1 + local disable_keystone=$1 + export DECKHAND_TEST_URL=$2 export DATABASE_URL=postgresql+psycopg2://deckhand:password@$POSTGRES_IP:5432/deckhand # Used by Deckhand's initialization script to search for config files. export DECKHAND_CONFIG_DIR=$CONF_DIR @@ -54,6 +78,11 @@ function gen_config { sed '1 a log_config_append = '"$CONF_DIR"'/logging.conf' $conf_file fi + if $disable_keystone; then + log_section "Toggling development_mode on to disable Keystone authentication." + sed -i -e 's/development_mode = false/development_mode = true/g' $conf_file + fi + echo $conf_file 1>&2 cat $conf_file 1>&2 @@ -63,33 +92,23 @@ function gen_config { function gen_paste { + ####################################### + # Generate sample paste.ini file + # Globals: + # CONF_DIR + # Arguments: + # disable_keystone: true or false + # Returns: + # None + ####################################### set -xe local disable_keystone=$1 if $disable_keystone; then - log_section Disabling Keystone authentication. - sed 's/authtoken api/api/' etc/deckhand/deckhand-paste.ini &> $CONF_DIR/deckhand-paste.ini + log_section "Using noauth-paste.ini to disable Keystone authentication." + cp etc/deckhand/noauth-paste.ini $CONF_DIR/noauth-paste.ini else cp etc/deckhand/deckhand-paste.ini $CONF_DIR/deckhand-paste.ini fi } - - -function gen_policy { - set -xe - - log_section "Creating policy file with liberal permissions" - - policy_file='etc/deckhand/policy.yaml.sample' - policy_pattern="deckhand\:" - - touch $CONF_DIR/policy.yaml - - sed -n "/$policy_pattern/p" "$policy_file" \ - | sed 's/^../\"/' \ - | sed 's/rule\:[A-Za-z\_\-]*/@/' > $CONF_DIR/policy.yaml - - echo $CONF_DIR/'policy.yaml' 1>&2 - cat $CONF_DIR/'policy.yaml' 1>&2 -} diff --git a/tools/functional-tests.sh b/tools/functional-tests.sh index 72cd84fe..772380c3 100755 --- a/tools/functional-tests.sh +++ b/tools/functional-tests.sh @@ -46,9 +46,8 @@ trap cleanup_deckhand EXIT function deploy_deckhand { - gen_config "http://localhost:9000" + gen_config true "127.0.0.1:9000" gen_paste true - gen_policy if [ -z "$DECKHAND_IMAGE" ]; then log_section "Running Deckhand via uwsgi." @@ -64,6 +63,13 @@ function deploy_deckhand { source $ROOTDIR/../entrypoint.sh server & else log_section "Running Deckhand via Docker." + + # If container is already running, kill it. + DECKHAND_ID=$(sudo docker ps --filter ancestor=$DECKHAND_IMAGE --format "{{.ID}}") + if [ -n "$DECKHAND_ID" ]; then + sudo docker stop $DECKHAND_ID + fi + sudo docker run \ --rm \ --net=host \ @@ -75,13 +81,13 @@ function deploy_deckhand { -p 9000:9000 \ -v $CONF_DIR:/etc/deckhand \ $DECKHAND_IMAGE server &> $STDOUT & + + DECKHAND_ID=$(sudo docker ps | grep deckhand | awk '{print $1}') + echo $DECKHAND_ID fi # Give the server a chance to come up. Better to poll a health check. sleep 5 - - DECKHAND_ID=$(sudo docker ps | grep deckhand | awk '{print $1}') - echo $DECKHAND_ID } diff --git a/tools/integration-tests.sh b/tools/integration-tests.sh index f9c8ba17..6714ef7b 100755 --- a/tools/integration-tests.sh +++ b/tools/integration-tests.sh @@ -141,7 +141,7 @@ function deploy_deckhand { openstack service list | grep deckhand openstack endpoint list | grep deckhand - gen_config $deckhand_endpoint + gen_config false $deckhand_endpoint gen_paste false # NOTE(fmontei): Generate an admin token instead of hacking a policy diff --git a/tox.ini b/tox.ini index 86537009..28f53eac 100644 --- a/tox.ini +++ b/tox.ini @@ -20,20 +20,20 @@ commands = [testenv:py27] commands = - {[testenv]commands} - stestr run {posargs} - stestr slowest + {[testenv]commands} + stestr run {posargs} + stestr slowest [testenv:py27-postgresql] commands = - {[testenv]commands} - {toxinidir}/tools/run_pifpaf.sh '{posargs}' + {[testenv]commands} + {toxinidir}/tools/run_pifpaf.sh '{posargs}' [testenv:py35] commands = - {[testenv]commands} - stestr run {posargs} - stestr slowest + {[testenv]commands} + stestr run {posargs} + stestr slowest [testenv:py35-postgresql] commands = @@ -41,6 +41,8 @@ commands = {toxinidir}/tools/run_pifpaf.sh '{posargs}' [testenv:functional] +# Always run functional tests using Python 3. +basepython=python3.5 setenv = VIRTUAL_ENV={envdir} OS_TEST_PATH=./deckhand/tests/functional LANGUAGE=en_US