Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change current working directory when reloading environment variables #613

Merged
merged 7 commits into from
Feb 5, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions azure_functions_worker/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
TYPED_DATA_COLLECTION = "TypedDataCollection"
RPC_HTTP_BODY_ONLY = "RpcHttpBodyOnly"
RPC_HTTP_TRIGGER_METADATA_REMOVED = "RpcHttpTriggerMetadataRemoved"

# Feature Flags (app settings)
PYTHON_ROLLBACK_CWD_PATH = "PYTHON_ROLLBACK_CWD_PATH"
15 changes: 15 additions & 0 deletions azure_functions_worker/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from .logging import error_logger, logger
from .tracing import marshall_exception_trace
from .utils.wrappers import disable_feature_by


class DispatcherMeta(type):
Expand Down Expand Up @@ -408,6 +409,12 @@ async def _handle__function_environment_reload_request(self, req):
logger.info('Unable to reload azure.functions. '
'Using default. Exception:\n{}'.format(ex))

# Change function app directory
if getattr(func_env_reload_request,
'function_app_directory', None):
self._change_cwd(
func_env_reload_request.function_app_directory)

success_response = protos.FunctionEnvironmentReloadResponse(
result=protos.StatusResult(
status=protos.StatusResult.Success))
Expand All @@ -426,6 +433,14 @@ async def _handle__function_environment_reload_request(self, req):
request_id=self.request_id,
function_environment_reload_response=failure_response)

@disable_feature_by(constants.PYTHON_ROLLBACK_CWD_PATH)
def _change_cwd(self, new_cwd: str):
if os.path.exists(new_cwd):
os.chdir(new_cwd)
logger.info('Changing current working directory to %s', new_cwd)
else:
logger.warn('Directory %s is not found when reloading', new_cwd)

def __run_sync_func(self, invocation_id, func, params):
# This helper exists because we need to access the current
# invocation_id from ThreadPoolExecutor's threads.
Expand Down
6 changes: 5 additions & 1 deletion azure_functions_worker/testutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@
"prefetchCount": 1000,
"batchCheckpointFrequency": 1
},
"functionTimeout": "00:05:00"
"functionTimeout": "00:05:00",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[1.*, 2.0.0)"
}
}
"""

Expand Down
Empty file.
15 changes: 15 additions & 0 deletions azure_functions_worker/utils/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import os


def is_true_like(setting: str):
if setting is None:
return False

return setting.lower().strip() in ['1', 'true', 't', 'yes', 'y']


def is_envvar_true(env_key: str):
if os.getenv(env_key) is None:
return False

return is_true_like(os.environ[env_key])
21 changes: 21 additions & 0 deletions azure_functions_worker/utils/wrappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from .common import is_envvar_true


def enable_feature_by(flag: str, default=None):
def decorate(func):
def call(*args, **kwargs):
if is_envvar_true(flag):
return func(*args, **kwargs)
return default
return call
return decorate


def disable_feature_by(flag: str, default=None):
def decorate(func):
def call(*args, **kwargs):
if not is_envvar_true(flag):
return func(*args, **kwargs)
return default
return call
return decorate
15 changes: 15 additions & 0 deletions tests/unittests/load_functions/parentmodule/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"scriptFile": "sub_module/main.py",
"bindings": [
{
"type": "httpTrigger",
"direction": "in",
"name": "req"
},
{
"type": "http",
"direction": "out",
"name": "$return"
}
]
}
1 change: 1 addition & 0 deletions tests/unittests/load_functions/parentmodule/module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MODULE_NAME = 'PARENTMODULE'
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .. import module


def main(req) -> str:
return module.__name__
15 changes: 15 additions & 0 deletions tests/unittests/load_functions/submodule/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"scriptFile": "main.py",
"bindings": [
{
"type": "httpTrigger",
"direction": "in",
"name": "req"
},
{
"type": "http",
"direction": "out",
"name": "$return"
}
]
}
5 changes: 5 additions & 0 deletions tests/unittests/load_functions/submodule/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .sub_module import module


def main(req) -> str:
return module.__name__
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MODULE_NAME = 'SUB_MODULE'
10 changes: 10 additions & 0 deletions tests/unittests/test_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ def test_loader_relimport(self):
self.assertEqual(r.status_code, 200)
self.assertEqual(r.text, '__app__.relimport.relative')

def test_loader_submodule(self):
r = self.webhost.request('GET', 'submodule')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.text, '__app__.submodule.sub_module.module')

def test_loader_parentmodule(self):
r = self.webhost.request('GET', 'parentmodule')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.text, '__app__.parentmodule.module')


class TestPluginLoader(testutils.AsyncTestCase):

Expand Down
26 changes: 22 additions & 4 deletions tests/unittests/test_rpc_messages.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
import os
import subprocess
import sys
import typing
import tempfile

from azure_functions_worker import protos
from azure_functions_worker import testutils


class TestGRPC(testutils.AsyncTestCase):
pre_test_env = os.environ.copy()
pre_test_cwd = os.getcwd()

def _reset_environ(self):
for key, value in self.pre_test_env.items():
os.environ[key] = value
os.chdir(self.pre_test_cwd)

async def _verify_environment_reloaded(self, test_env):
async def _verify_environment_reloaded(
self,
test_env: typing.Dict[str, str] = {},
test_cwd: str = os.getcwd()):
request = protos.FunctionEnvironmentReloadRequest(
environment_variables=test_env)
environment_variables=test_env,
function_app_directory=test_cwd)

request_msg = protos.StreamingMessage(
request_id='0',
Expand All @@ -29,18 +37,28 @@ async def _verify_environment_reloaded(self, test_env):

environ_dict = os.environ.copy()
self.assertDictEqual(environ_dict, test_env)
self.assertEqual(os.getcwd(), test_cwd)
status = r.function_environment_reload_response.result.status
self.assertEqual(status, protos.StatusResult.Success)
finally:
self._reset_environ()

async def test_multiple_env_vars_load(self):
test_env = {'TEST_KEY': 'foo', 'HELLO': 'world'}
await self._verify_environment_reloaded(test_env)
await self._verify_environment_reloaded(test_env=test_env)

async def test_empty_env_vars_load(self):
test_env = {}
await self._verify_environment_reloaded(test_env)
await self._verify_environment_reloaded(test_env=test_env)

async def test_changing_current_working_directory(self):
test_cwd = tempfile.gettempdir()
await self._verify_environment_reloaded(test_cwd=test_cwd)

async def test_reload_env_message(self):
test_env = {'TEST_KEY': 'foo', 'HELLO': 'world'}
test_cwd = tempfile.gettempdir()
await self._verify_environment_reloaded(test_env, test_cwd)

def _verify_sys_path_import(self, result, expected_output):
try:
Expand Down
122 changes: 122 additions & 0 deletions tests/unittests/test_utilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import os
import unittest
import typing

from azure_functions_worker.utils import common, wrappers


TEST_FEATURE_FLAG = "APP_SETTING_FEATURE_FLAG"
FEATURE_DEFAULT = 42


class MockFeature:
@wrappers.enable_feature_by(TEST_FEATURE_FLAG)
def mock_feature_enabled(self, output: typing.List[str]) -> str:
result = 'mock_feature_enabled'
output.append(result)
return result

@wrappers.disable_feature_by(TEST_FEATURE_FLAG)
def mock_feature_disabled(self, output: typing.List[str]) -> str:
result = 'mock_feature_disabled'
output.append(result)
return result

@wrappers.enable_feature_by(TEST_FEATURE_FLAG, FEATURE_DEFAULT)
def mock_feature_default(self, output: typing.List[str]) -> str:
result = 'mock_feature_default'
output.append(result)
return result


class TestUtilities(unittest.TestCase):

def setUp(self):
self._pre_env = dict(os.environ)

def tearDown(self):
os.environ.clear()
os.environ.update(self._pre_env)

def test_is_true_like_accepted(self):
self.assertTrue(common.is_true_like('1'))
self.assertTrue(common.is_true_like('true'))
self.assertTrue(common.is_true_like('T'))
self.assertTrue(common.is_true_like('YES'))
self.assertTrue(common.is_true_like('y'))

def test_is_true_like_rejected(self):
self.assertFalse(common.is_true_like(None))
self.assertFalse(common.is_true_like(''))
self.assertFalse(common.is_true_like('secret'))

def test_is_envvar_true(self):
os.environ[TEST_FEATURE_FLAG] = 'true'
self.assertTrue(common.is_envvar_true(TEST_FEATURE_FLAG))

def test_is_envvar_not_true_on_unset(self):
self._unset_feature_flag()
self.assertFalse(common.is_envvar_true(TEST_FEATURE_FLAG))

def test_disable_feature_with_no_feature_flag(self):
mock_feature = MockFeature()
output = []
result = mock_feature.mock_feature_enabled(output)
self.assertIsNone(result)
self.assertListEqual(output, [])

def test_enable_feature_with_feature_flag(self):
feature_flag = TEST_FEATURE_FLAG
os.environ[feature_flag] = '1'
mock_feature = MockFeature()
output = []
result = mock_feature.mock_feature_enabled(output)
self.assertEqual(result, 'mock_feature_enabled')
self.assertListEqual(output, ['mock_feature_enabled'])

def test_enable_feature_with_no_rollback_flag(self):
mock_feature = MockFeature()
output = []
result = mock_feature.mock_feature_disabled(output)
self.assertEqual(result, 'mock_feature_disabled')
self.assertListEqual(output, ['mock_feature_disabled'])

def test_disable_feature_with_rollback_flag(self):
rollback_flag = TEST_FEATURE_FLAG
os.environ[rollback_flag] = '1'
mock_feature = MockFeature()
output = []
result = mock_feature.mock_feature_disabled(output)
self.assertIsNone(result)
self.assertListEqual(output, [])

def test_enable_feature_with_rollback_flag_is_false(self):
rollback_flag = TEST_FEATURE_FLAG
os.environ[rollback_flag] = 'false'
mock_feature = MockFeature()
output = []
result = mock_feature.mock_feature_disabled(output)
self.assertEqual(result, 'mock_feature_disabled')
self.assertListEqual(output, ['mock_feature_disabled'])

def test_fail_to_enable_feature_return_default_value(self):
mock_feature = MockFeature()
output = []
result = mock_feature.mock_feature_default(output)
self.assertEqual(result, FEATURE_DEFAULT)
self.assertListEqual(output, [])

def test_disable_feature_with_false_flag_return_default_value(self):
feature_flag = TEST_FEATURE_FLAG
os.environ[feature_flag] = 'false'
mock_feature = MockFeature()
output = []
result = mock_feature.mock_feature_default(output)
self.assertEqual(result, FEATURE_DEFAULT)
self.assertListEqual(output, [])

def _unset_feature_flag(self):
try:
os.environ.pop(TEST_FEATURE_FLAG)
except KeyError:
pass