Skip to content

Commit aa4bea9

Browse files
authored
Change current working directory when reloading environment variables (#613)
* Update environment reload logic * Add feature flag control * Added two more test cases * Fix linting * Address CR issues * Add extension bundle for e2e test * Retrigger CI
1 parent 3869532 commit aa4bea9

File tree

17 files changed

+255
-5
lines changed

17 files changed

+255
-5
lines changed

azure_functions_worker/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@
33
TYPED_DATA_COLLECTION = "TypedDataCollection"
44
RPC_HTTP_BODY_ONLY = "RpcHttpBodyOnly"
55
RPC_HTTP_TRIGGER_METADATA_REMOVED = "RpcHttpTriggerMetadataRemoved"
6+
7+
# Feature Flags (app settings)
8+
PYTHON_ROLLBACK_CWD_PATH = "PYTHON_ROLLBACK_CWD_PATH"

azure_functions_worker/dispatcher.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from .logging import error_logger, logger
2626
from .tracing import marshall_exception_trace
27+
from .utils.wrappers import disable_feature_by
2728

2829

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

412+
# Change function app directory
413+
if getattr(func_env_reload_request,
414+
'function_app_directory', None):
415+
self._change_cwd(
416+
func_env_reload_request.function_app_directory)
417+
411418
success_response = protos.FunctionEnvironmentReloadResponse(
412419
result=protos.StatusResult(
413420
status=protos.StatusResult.Success))
@@ -426,6 +433,14 @@ async def _handle__function_environment_reload_request(self, req):
426433
request_id=self.request_id,
427434
function_environment_reload_response=failure_response)
428435

436+
@disable_feature_by(constants.PYTHON_ROLLBACK_CWD_PATH)
437+
def _change_cwd(self, new_cwd: str):
438+
if os.path.exists(new_cwd):
439+
os.chdir(new_cwd)
440+
logger.info('Changing current working directory to %s', new_cwd)
441+
else:
442+
logger.warn('Directory %s is not found when reloading', new_cwd)
443+
429444
def __run_sync_func(self, invocation_id, func, params):
430445
# This helper exists because we need to access the current
431446
# invocation_id from ThreadPoolExecutor's threads.

azure_functions_worker/testutils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@
6767
"prefetchCount": 1000,
6868
"batchCheckpointFrequency": 1
6969
},
70-
"functionTimeout": "00:05:00"
70+
"functionTimeout": "00:05:00",
71+
"extensionBundle": {
72+
"id": "Microsoft.Azure.Functions.ExtensionBundle",
73+
"version": "[1.*, 2.0.0)"
74+
}
7175
}
7276
"""
7377

azure_functions_worker/utils/__init__.py

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import os
2+
3+
4+
def is_true_like(setting: str):
5+
if setting is None:
6+
return False
7+
8+
return setting.lower().strip() in ['1', 'true', 't', 'yes', 'y']
9+
10+
11+
def is_envvar_true(env_key: str):
12+
if os.getenv(env_key) is None:
13+
return False
14+
15+
return is_true_like(os.environ[env_key])
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from .common import is_envvar_true
2+
3+
4+
def enable_feature_by(flag: str, default=None):
5+
def decorate(func):
6+
def call(*args, **kwargs):
7+
if is_envvar_true(flag):
8+
return func(*args, **kwargs)
9+
return default
10+
return call
11+
return decorate
12+
13+
14+
def disable_feature_by(flag: str, default=None):
15+
def decorate(func):
16+
def call(*args, **kwargs):
17+
if not is_envvar_true(flag):
18+
return func(*args, **kwargs)
19+
return default
20+
return call
21+
return decorate
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"scriptFile": "sub_module/main.py",
3+
"bindings": [
4+
{
5+
"type": "httpTrigger",
6+
"direction": "in",
7+
"name": "req"
8+
},
9+
{
10+
"type": "http",
11+
"direction": "out",
12+
"name": "$return"
13+
}
14+
]
15+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
MODULE_NAME = 'PARENTMODULE'

tests/unittests/load_functions/parentmodule/sub_module/__init__.py

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .. import module
2+
3+
4+
def main(req) -> str:
5+
return module.__name__
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"scriptFile": "main.py",
3+
"bindings": [
4+
{
5+
"type": "httpTrigger",
6+
"direction": "in",
7+
"name": "req"
8+
},
9+
{
10+
"type": "http",
11+
"direction": "out",
12+
"name": "$return"
13+
}
14+
]
15+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .sub_module import module
2+
3+
4+
def main(req) -> str:
5+
return module.__name__

tests/unittests/load_functions/submodule/sub_module/__init__.py

Whitespace-only changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
MODULE_NAME = 'SUB_MODULE'

tests/unittests/test_loader.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ def test_loader_relimport(self):
3333
self.assertEqual(r.status_code, 200)
3434
self.assertEqual(r.text, '__app__.relimport.relative')
3535

36+
def test_loader_submodule(self):
37+
r = self.webhost.request('GET', 'submodule')
38+
self.assertEqual(r.status_code, 200)
39+
self.assertEqual(r.text, '__app__.submodule.sub_module.module')
40+
41+
def test_loader_parentmodule(self):
42+
r = self.webhost.request('GET', 'parentmodule')
43+
self.assertEqual(r.status_code, 200)
44+
self.assertEqual(r.text, '__app__.parentmodule.module')
45+
3646

3747
class TestPluginLoader(testutils.AsyncTestCase):
3848

tests/unittests/test_rpc_messages.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
import os
22
import subprocess
33
import sys
4+
import typing
5+
import tempfile
46

57
from azure_functions_worker import protos
68
from azure_functions_worker import testutils
79

810

911
class TestGRPC(testutils.AsyncTestCase):
1012
pre_test_env = os.environ.copy()
13+
pre_test_cwd = os.getcwd()
1114

1215
def _reset_environ(self):
1316
for key, value in self.pre_test_env.items():
1417
os.environ[key] = value
18+
os.chdir(self.pre_test_cwd)
1519

16-
async def _verify_environment_reloaded(self, test_env):
20+
async def _verify_environment_reloaded(
21+
self,
22+
test_env: typing.Dict[str, str] = {},
23+
test_cwd: str = os.getcwd()):
1724
request = protos.FunctionEnvironmentReloadRequest(
18-
environment_variables=test_env)
25+
environment_variables=test_env,
26+
function_app_directory=test_cwd)
1927

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

3038
environ_dict = os.environ.copy()
3139
self.assertDictEqual(environ_dict, test_env)
40+
self.assertEqual(os.getcwd(), test_cwd)
3241
status = r.function_environment_reload_response.result.status
3342
self.assertEqual(status, protos.StatusResult.Success)
3443
finally:
3544
self._reset_environ()
3645

3746
async def test_multiple_env_vars_load(self):
3847
test_env = {'TEST_KEY': 'foo', 'HELLO': 'world'}
39-
await self._verify_environment_reloaded(test_env)
48+
await self._verify_environment_reloaded(test_env=test_env)
4049

4150
async def test_empty_env_vars_load(self):
4251
test_env = {}
43-
await self._verify_environment_reloaded(test_env)
52+
await self._verify_environment_reloaded(test_env=test_env)
53+
54+
async def test_changing_current_working_directory(self):
55+
test_cwd = tempfile.gettempdir()
56+
await self._verify_environment_reloaded(test_cwd=test_cwd)
57+
58+
async def test_reload_env_message(self):
59+
test_env = {'TEST_KEY': 'foo', 'HELLO': 'world'}
60+
test_cwd = tempfile.gettempdir()
61+
await self._verify_environment_reloaded(test_env, test_cwd)
4462

4563
def _verify_sys_path_import(self, result, expected_output):
4664
try:

tests/unittests/test_utilities.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import os
2+
import unittest
3+
import typing
4+
5+
from azure_functions_worker.utils import common, wrappers
6+
7+
8+
TEST_FEATURE_FLAG = "APP_SETTING_FEATURE_FLAG"
9+
FEATURE_DEFAULT = 42
10+
11+
12+
class MockFeature:
13+
@wrappers.enable_feature_by(TEST_FEATURE_FLAG)
14+
def mock_feature_enabled(self, output: typing.List[str]) -> str:
15+
result = 'mock_feature_enabled'
16+
output.append(result)
17+
return result
18+
19+
@wrappers.disable_feature_by(TEST_FEATURE_FLAG)
20+
def mock_feature_disabled(self, output: typing.List[str]) -> str:
21+
result = 'mock_feature_disabled'
22+
output.append(result)
23+
return result
24+
25+
@wrappers.enable_feature_by(TEST_FEATURE_FLAG, FEATURE_DEFAULT)
26+
def mock_feature_default(self, output: typing.List[str]) -> str:
27+
result = 'mock_feature_default'
28+
output.append(result)
29+
return result
30+
31+
32+
class TestUtilities(unittest.TestCase):
33+
34+
def setUp(self):
35+
self._pre_env = dict(os.environ)
36+
37+
def tearDown(self):
38+
os.environ.clear()
39+
os.environ.update(self._pre_env)
40+
41+
def test_is_true_like_accepted(self):
42+
self.assertTrue(common.is_true_like('1'))
43+
self.assertTrue(common.is_true_like('true'))
44+
self.assertTrue(common.is_true_like('T'))
45+
self.assertTrue(common.is_true_like('YES'))
46+
self.assertTrue(common.is_true_like('y'))
47+
48+
def test_is_true_like_rejected(self):
49+
self.assertFalse(common.is_true_like(None))
50+
self.assertFalse(common.is_true_like(''))
51+
self.assertFalse(common.is_true_like('secret'))
52+
53+
def test_is_envvar_true(self):
54+
os.environ[TEST_FEATURE_FLAG] = 'true'
55+
self.assertTrue(common.is_envvar_true(TEST_FEATURE_FLAG))
56+
57+
def test_is_envvar_not_true_on_unset(self):
58+
self._unset_feature_flag()
59+
self.assertFalse(common.is_envvar_true(TEST_FEATURE_FLAG))
60+
61+
def test_disable_feature_with_no_feature_flag(self):
62+
mock_feature = MockFeature()
63+
output = []
64+
result = mock_feature.mock_feature_enabled(output)
65+
self.assertIsNone(result)
66+
self.assertListEqual(output, [])
67+
68+
def test_enable_feature_with_feature_flag(self):
69+
feature_flag = TEST_FEATURE_FLAG
70+
os.environ[feature_flag] = '1'
71+
mock_feature = MockFeature()
72+
output = []
73+
result = mock_feature.mock_feature_enabled(output)
74+
self.assertEqual(result, 'mock_feature_enabled')
75+
self.assertListEqual(output, ['mock_feature_enabled'])
76+
77+
def test_enable_feature_with_no_rollback_flag(self):
78+
mock_feature = MockFeature()
79+
output = []
80+
result = mock_feature.mock_feature_disabled(output)
81+
self.assertEqual(result, 'mock_feature_disabled')
82+
self.assertListEqual(output, ['mock_feature_disabled'])
83+
84+
def test_disable_feature_with_rollback_flag(self):
85+
rollback_flag = TEST_FEATURE_FLAG
86+
os.environ[rollback_flag] = '1'
87+
mock_feature = MockFeature()
88+
output = []
89+
result = mock_feature.mock_feature_disabled(output)
90+
self.assertIsNone(result)
91+
self.assertListEqual(output, [])
92+
93+
def test_enable_feature_with_rollback_flag_is_false(self):
94+
rollback_flag = TEST_FEATURE_FLAG
95+
os.environ[rollback_flag] = 'false'
96+
mock_feature = MockFeature()
97+
output = []
98+
result = mock_feature.mock_feature_disabled(output)
99+
self.assertEqual(result, 'mock_feature_disabled')
100+
self.assertListEqual(output, ['mock_feature_disabled'])
101+
102+
def test_fail_to_enable_feature_return_default_value(self):
103+
mock_feature = MockFeature()
104+
output = []
105+
result = mock_feature.mock_feature_default(output)
106+
self.assertEqual(result, FEATURE_DEFAULT)
107+
self.assertListEqual(output, [])
108+
109+
def test_disable_feature_with_false_flag_return_default_value(self):
110+
feature_flag = TEST_FEATURE_FLAG
111+
os.environ[feature_flag] = 'false'
112+
mock_feature = MockFeature()
113+
output = []
114+
result = mock_feature.mock_feature_default(output)
115+
self.assertEqual(result, FEATURE_DEFAULT)
116+
self.assertListEqual(output, [])
117+
118+
def _unset_feature_flag(self):
119+
try:
120+
os.environ.pop(TEST_FEATURE_FLAG)
121+
except KeyError:
122+
pass

0 commit comments

Comments
 (0)