Skip to content

Commit 5c8df9d

Browse files
Gavin AguiarGavin Aguiar
authored andcommitted
Adding docker tests
1 parent b2a0bf6 commit 5c8df9d

File tree

5 files changed

+199
-6
lines changed

5 files changed

+199
-6
lines changed

azure_functions_worker/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,7 @@
5151
SCRIPT_FILE_NAME = "function_app.py"
5252

5353
PYTHON_LANGUAGE_RUNTIME = "python"
54+
55+
# CI test constants
56+
CONSUMPTION_DOCKER_TEST = True
57+
DEDICATED_DOCKER_TEST = False

tests/endtoend/http_functions/default_template/function.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"scriptFile": "__init__.py",
33
"bindings": [
44
{
5-
"authLevel": "function",
5+
"authLevel": "anonymous",
66
"type": "httpTrigger",
77
"direction": "in",
88
"name": "req",

tests/utils/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
# PROJECT_ROOT refers to the path to azure-functions-python-worker
66
# TODO: Find root folder without .parent
77
PROJECT_ROOT = pathlib.Path(__file__).parent.parent.parent
8+
TESTS_ROOT = PROJECT_ROOT / 'tests'
9+
WORKER_CONFIG = PROJECT_ROOT / '.testconfig'
810

911
# E2E Integration Flags and Configurations
1012
PYAZURE_INTEGRATION_TEST = "PYAZURE_INTEGRATION_TEST"

tests/utils/testutils.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@
4444
from azure_functions_worker.constants import (
4545
PYAZURE_WEBHOST_DEBUG,
4646
FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED,
47-
UNIX_SHARED_MEMORY_DIRECTORIES
47+
UNIX_SHARED_MEMORY_DIRECTORIES, CONSUMPTION_DOCKER_TEST,
48+
DEDICATED_DOCKER_TEST
4849
)
4950
from azure_functions_worker.utils.common import is_envvar_true, get_app_setting
5051
from tests.utils.constants import PYAZURE_WORKER_DIR, \
51-
PYAZURE_INTEGRATION_TEST, PROJECT_ROOT
52+
PYAZURE_INTEGRATION_TEST, PROJECT_ROOT, WORKER_CONFIG
53+
from tests.utils.testutils_docker import WebHostConsumption
5254

5355
TESTS_ROOT = PROJECT_ROOT / 'tests'
5456
E2E_TESTS_FOLDER = pathlib.Path('endtoend')
@@ -62,7 +64,6 @@
6264
EXTENSIONS_PATH = PROJECT_ROOT / 'build' / 'extensions' / 'bin'
6365
FUNCS_PATH = TESTS_ROOT / UNIT_TESTS_FOLDER / 'http_functions'
6466
WORKER_PATH = PROJECT_ROOT / 'python' / 'test'
65-
WORKER_CONFIG = PROJECT_ROOT / '.testconfig'
6667
ON_WINDOWS = platform.system() == 'Windows'
6768
LOCALHOST = "127.0.0.1"
6869

@@ -221,8 +222,13 @@ def setUpClass(cls):
221222

222223
_setup_func_app(TESTS_ROOT / script_dir)
223224
try:
224-
cls.webhost = start_webhost(script_dir=script_dir,
225-
stdout=cls.host_stdout)
225+
if is_envvar_true(CONSUMPTION_DOCKER_TEST):
226+
cls.webhost = WebHostConsumption(script_dir).spawn_container()
227+
elif is_envvar_true(DEDICATED_DOCKER_TEST):
228+
cls.webhost = WebHostConsumption(script_dir).spawn_container()
229+
else:
230+
cls.webhost = start_webhost(script_dir=script_dir,
231+
stdout=cls.host_stdout)
226232
except Exception:
227233
_teardown_func_app(TESTS_ROOT / script_dir)
228234
raise
@@ -917,6 +923,7 @@ def start_webhost(*, script_dir=None, stdout=None):
917923
stdout = subprocess.DEVNULL
918924

919925
port = _find_open_port()
926+
920927
proc = popen_webhost(stdout=stdout, stderr=subprocess.STDOUT,
921928
script_root=script_root, port=port)
922929
time.sleep(10) # Giving host some time to start fully.

tests/utils/testutils_docker.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import configparser
2+
import os
3+
import re
4+
import subprocess
5+
import sys
6+
import unittest
7+
import uuid
8+
from time import sleep
9+
10+
import requests
11+
12+
from tests.utils.constants import PROJECT_ROOT, TESTS_ROOT, WORKER_CONFIG
13+
14+
_DOCKER_PATH = "DOCKER_PATH"
15+
_DOCKER_DEFAULT_PATH = "docker"
16+
_HOST_VERSION = "4"
17+
_docker_cmd = os.getenv(_DOCKER_PATH, _DOCKER_DEFAULT_PATH)
18+
_addr = ""
19+
_python_version = f'{sys.version_info.major}.{sys.version_info.minor}'
20+
_uuid = str(uuid.uuid4())
21+
_MESH_IMAGE_URL = "https://mcr.microsoft.com/v2/azure-functions/mesh/tags/list"
22+
_MESH_IMAGE_REPO = "mcr.microsoft.com/azure-functions/mesh"
23+
_IMAGE_URL = "https://mcr.microsoft.com/v2/azure-functions/python/tags/list"
24+
_IMAGE_REPO = "mcr.microsoft.com/azure-functions/python"
25+
26+
27+
class WebHostProxy:
28+
29+
def __init__(self, proc, addr):
30+
self._proc = proc
31+
self._addr = addr
32+
33+
def request(self, meth, funcname, *args, **kwargs):
34+
request_method = getattr(requests, meth.lower())
35+
params = dict(kwargs.pop('params', {}))
36+
no_prefix = kwargs.pop('no_prefix', False)
37+
38+
return request_method(
39+
self._addr + ('/' if no_prefix else '/api/') + funcname,
40+
*args, params=params, **kwargs)
41+
42+
def close(self) -> bool:
43+
"""Kill a container by its name. Returns True on success.
44+
"""
45+
kill_cmd = [_docker_cmd, "rm", "-f", _uuid]
46+
kill_process = subprocess.run(args=kill_cmd, stdout=subprocess.DEVNULL)
47+
exit_code = kill_process.returncode
48+
49+
return exit_code == 0
50+
51+
52+
class WebHostDockerContainerBase(unittest.TestCase):
53+
54+
def find_latest_mesh_image(self,
55+
image_repo: str,
56+
image_url: str) -> str:
57+
58+
regex = re.compile(_HOST_VERSION + r'.\d+.\d+-python' + _python_version)
59+
60+
response = requests.get(image_url, allow_redirects=True)
61+
if not response.ok:
62+
raise RuntimeError(f'Failed to query latest image for v4'
63+
f' Python {_python_version}.'
64+
f' Status {response.status_code}')
65+
66+
tag_list = response.json().get('tags', [])
67+
# Removing images with a -upgrade and -slim. Upgrade images were
68+
# temporary images used to onboard customers from a previous version.
69+
# These images are no longer used.
70+
tag_list = [x.strip("-upgrade") for x in tag_list]
71+
tag_list = [x.strip("-slim") for x in tag_list]
72+
73+
# Listing all the versions from the tags with suffix -python<version>
74+
python_versions = list(filter(regex.match, tag_list))
75+
76+
# sorting all the python versions based on the runtime version and
77+
# getting the latest released runtime version for python.
78+
latest_version = sorted(python_versions, key=lambda x: float(
79+
x.split(_HOST_VERSION + '.')[-1].split("-python")[0]))[-1]
80+
81+
image_tag = f'{image_repo}:{latest_version}'
82+
return image_tag
83+
84+
def create_container(self, image_repo: str, image_url: str,
85+
script_path: str):
86+
"""Create a docker container and record its port. Create a docker
87+
container according to the image name. Return the port of container.
88+
"""
89+
90+
worker_path = os.path.join(PROJECT_ROOT, 'azure_functions_worker')
91+
script_path = os.path.join(TESTS_ROOT, script_path)
92+
93+
image = self.find_latest_mesh_image(image_repo, image_url)
94+
95+
container_worker_path = (
96+
f"/azure-functions-host/workers/python/{_python_version}/"
97+
"LINUX/X64/azure_functions_worker"
98+
)
99+
100+
function_path = "/home/site/wwwroot"
101+
102+
env = {"FUNCTIONS_EXTENSION_VERSION": "4",
103+
"FUNCTIONS_WORKER_RUNTIME": "python",
104+
"FUNCTIONS_WORKER_RUNTIME_VERSION": _python_version,
105+
"WEBSITE_SITE_NAME": _uuid,
106+
"WEBSITE_HOSTNAME": f"{_uuid}.azurewebsites.com"}
107+
108+
testconfig = None
109+
storage_key: str = os.getenv("AzureWebJobsStorage")
110+
if not storage_key and WORKER_CONFIG.exists():
111+
testconfig = configparser.ConfigParser()
112+
testconfig.read(WORKER_CONFIG)
113+
114+
if testconfig and 'azure' in testconfig:
115+
storage_key = testconfig['azure'].get('storage_key')
116+
117+
if not storage_key:
118+
raise RuntimeError('Environment variable AzureWebJobsStorage '
119+
'is required before running docker test')
120+
121+
run_cmd = []
122+
run_cmd.extend([_docker_cmd, "run", "-p", "0:80", "-d"])
123+
run_cmd.extend(["--name", _uuid, "--privileged"])
124+
run_cmd.extend(["--cap-add", "SYS_ADMIN"])
125+
run_cmd.extend(["--device", "/dev/fuse"])
126+
run_cmd.extend(["-e", f"CONTAINER_NAME={_uuid}"])
127+
run_cmd.extend(["-e", f"AzureWebJobsStorage={storage_key}"])
128+
run_cmd.extend(["-v", f'{worker_path}:{container_worker_path}'])
129+
run_cmd.extend(["-v", f'{script_path}:{function_path}'])
130+
131+
run_cmd.append(image)
132+
133+
run_process = subprocess.run(args=run_cmd,
134+
env=env,
135+
stdout=subprocess.PIPE,
136+
stderr=subprocess.PIPE)
137+
138+
if run_process.returncode != 0:
139+
raise RuntimeError('Failed to spawn docker container for'
140+
f' {image} with uuid {_uuid}.'
141+
f' stderr: {run_process.stderr}')
142+
143+
# Wait for six seconds for the port to expose
144+
sleep(6)
145+
146+
# Acquire the port number of the container
147+
port_cmd = [_docker_cmd, "port", _uuid]
148+
port_process = subprocess.run(args=port_cmd,
149+
stdout=subprocess.PIPE,
150+
stderr=subprocess.PIPE)
151+
if port_process.returncode != 0:
152+
raise RuntimeError(f'Failed to acquire port for {_uuid}.'
153+
f' stderr: {port_process.stderr}')
154+
port_number = port_process.stdout.decode().strip('\n').split(':')[-1]
155+
156+
# Wait for six seconds for the container to be in ready state
157+
sleep(10)
158+
self._addr = f'http://localhost:{port_number}'
159+
return WebHostProxy(run_process, self._addr)
160+
161+
162+
class WebHostConsumption(WebHostDockerContainerBase):
163+
164+
def __init__(self, script_path: str):
165+
self.script_path = script_path
166+
167+
def spawn_container(self):
168+
return self.create_container(_MESH_IMAGE_REPO,
169+
_MESH_IMAGE_URL,
170+
self.script_path)
171+
172+
173+
class WebHostDedicated(WebHostDockerContainerBase):
174+
175+
def __init__(self, script_path: str):
176+
self.script_path = script_path
177+
178+
def spawn_container(self):
179+
return self.create_container(_IMAGE_REPO, _IMAGE_URL,
180+
self.script_path)

0 commit comments

Comments
 (0)