diff --git a/azure_functions_worker/constants.py b/azure_functions_worker/constants.py index 3edc039b0..a67eb144b 100644 --- a/azure_functions_worker/constants.py +++ b/azure_functions_worker/constants.py @@ -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" diff --git a/azure_functions_worker/dispatcher.py b/azure_functions_worker/dispatcher.py index 62aedfc47..69f7d5d84 100644 --- a/azure_functions_worker/dispatcher.py +++ b/azure_functions_worker/dispatcher.py @@ -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): @@ -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)) @@ -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. diff --git a/azure_functions_worker/testutils.py b/azure_functions_worker/testutils.py index f8b5e905e..966539a4a 100644 --- a/azure_functions_worker/testutils.py +++ b/azure_functions_worker/testutils.py @@ -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)" + } } """ diff --git a/azure_functions_worker/utils/__init__.py b/azure_functions_worker/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/azure_functions_worker/utils/common.py b/azure_functions_worker/utils/common.py new file mode 100644 index 000000000..1c232f14e --- /dev/null +++ b/azure_functions_worker/utils/common.py @@ -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]) diff --git a/azure_functions_worker/utils/wrappers.py b/azure_functions_worker/utils/wrappers.py new file mode 100644 index 000000000..15345c4d5 --- /dev/null +++ b/azure_functions_worker/utils/wrappers.py @@ -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 diff --git a/tests/unittests/load_functions/parentmodule/function.json b/tests/unittests/load_functions/parentmodule/function.json new file mode 100644 index 000000000..12ff3cc01 --- /dev/null +++ b/tests/unittests/load_functions/parentmodule/function.json @@ -0,0 +1,15 @@ +{ + "scriptFile": "sub_module/main.py", + "bindings": [ + { + "type": "httpTrigger", + "direction": "in", + "name": "req" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} diff --git a/tests/unittests/load_functions/parentmodule/module.py b/tests/unittests/load_functions/parentmodule/module.py new file mode 100644 index 000000000..2f4f1a61d --- /dev/null +++ b/tests/unittests/load_functions/parentmodule/module.py @@ -0,0 +1 @@ +MODULE_NAME = 'PARENTMODULE' diff --git a/tests/unittests/load_functions/parentmodule/sub_module/__init__.py b/tests/unittests/load_functions/parentmodule/sub_module/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unittests/load_functions/parentmodule/sub_module/main.py b/tests/unittests/load_functions/parentmodule/sub_module/main.py new file mode 100644 index 000000000..de09a6cec --- /dev/null +++ b/tests/unittests/load_functions/parentmodule/sub_module/main.py @@ -0,0 +1,5 @@ +from .. import module + + +def main(req) -> str: + return module.__name__ diff --git a/tests/unittests/load_functions/submodule/function.json b/tests/unittests/load_functions/submodule/function.json new file mode 100644 index 000000000..5d4d8285f --- /dev/null +++ b/tests/unittests/load_functions/submodule/function.json @@ -0,0 +1,15 @@ +{ + "scriptFile": "main.py", + "bindings": [ + { + "type": "httpTrigger", + "direction": "in", + "name": "req" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} diff --git a/tests/unittests/load_functions/submodule/main.py b/tests/unittests/load_functions/submodule/main.py new file mode 100644 index 000000000..a8a4d1459 --- /dev/null +++ b/tests/unittests/load_functions/submodule/main.py @@ -0,0 +1,5 @@ +from .sub_module import module + + +def main(req) -> str: + return module.__name__ diff --git a/tests/unittests/load_functions/submodule/sub_module/__init__.py b/tests/unittests/load_functions/submodule/sub_module/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unittests/load_functions/submodule/sub_module/module.py b/tests/unittests/load_functions/submodule/sub_module/module.py new file mode 100644 index 000000000..0dfdb5931 --- /dev/null +++ b/tests/unittests/load_functions/submodule/sub_module/module.py @@ -0,0 +1 @@ +MODULE_NAME = 'SUB_MODULE' diff --git a/tests/unittests/test_loader.py b/tests/unittests/test_loader.py index d65aff639..13ec18aa6 100644 --- a/tests/unittests/test_loader.py +++ b/tests/unittests/test_loader.py @@ -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): diff --git a/tests/unittests/test_rpc_messages.py b/tests/unittests/test_rpc_messages.py index 796523078..40fcd104e 100644 --- a/tests/unittests/test_rpc_messages.py +++ b/tests/unittests/test_rpc_messages.py @@ -1,6 +1,8 @@ import os import subprocess import sys +import typing +import tempfile from azure_functions_worker import protos from azure_functions_worker import testutils @@ -8,14 +10,20 @@ 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', @@ -29,6 +37,7 @@ 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: @@ -36,11 +45,20 @@ async def _verify_environment_reloaded(self, test_env): 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: diff --git a/tests/unittests/test_utilities.py b/tests/unittests/test_utilities.py new file mode 100644 index 000000000..5b8f0c7b1 --- /dev/null +++ b/tests/unittests/test_utilities.py @@ -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