Skip to content

Commit 7630661

Browse files
authored
Add Linux Consumption Testing Utils (#882)
* Add Linux Consumption WebHost Controller * Add safe kill container method * Header generator * Add specialization encryptor * Add testing utils for Linux Consumption * Link docker executable into pytest * Test with docker --help * Install fuse * Fix test cases * Test Docker * Introduce pytest from python * Use privileged permissions to execute python * Elevate superuser permission * recover ci e2e workflow * Is docker working on GitHub Action * Extract arguments * Use privileged mode * Fix pylint * address PR issues
1 parent f5271ce commit 7630661

File tree

4 files changed

+408
-0
lines changed

4 files changed

+408
-0
lines changed

azure_functions_worker/testutils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
ON_WINDOWS = platform.system() == 'Windows'
6868
LOCALHOST = "127.0.0.1"
6969

70+
# The template of host.json that will be applied to each test functions
7071
HOST_JSON_TEMPLATE = """\
7172
{
7273
"version": "2.0",
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from typing import Dict
5+
6+
import base64
7+
import json
8+
import os
9+
import re
10+
import subprocess
11+
import sys
12+
import time
13+
import uuid
14+
15+
from Crypto.Cipher import AES
16+
from Crypto.Hash.SHA256 import SHA256Hash
17+
from Crypto.Util.Padding import pad
18+
import requests
19+
20+
# Linux Consumption Testing Constants
21+
_DOCKER_PATH = "DOCKER_PATH"
22+
_DOCKER_DEFAULT_PATH = "docker"
23+
_MESH_IMAGE_URL = "https://mcr.microsoft.com/v2/azure-functions/mesh/tags/list"
24+
_MESH_IMAGE_REPO = "mcr.microsoft.com/azure-functions/mesh"
25+
_DUMMY_CONT_KEY = "MDEyMzQ1Njc4OUFCQ0RFRjAxMjM0NTY3ODlBQkNERUY="
26+
27+
28+
class LinuxConsumptionWebHostController:
29+
"""A controller for spawning mesh Docker container and apply multiple
30+
test cases on it.
31+
"""
32+
33+
_docker_cmd = os.getenv(_DOCKER_PATH, _DOCKER_DEFAULT_PATH)
34+
_ports: Dict[str, str] = {} # { uuid: port }
35+
_mesh_images: Dict[str, str] = {} # { host version: image tag }
36+
37+
def __init__(self, host_version: str, python_version: str):
38+
"""Initialize a new container for
39+
"""
40+
self._uuid = str(uuid.uuid4())
41+
self._host_version = host_version # "3"
42+
self._py_version = python_version # "3.9"
43+
44+
@property
45+
def url(self) -> str:
46+
if self._uuid not in self._ports:
47+
raise RuntimeError(f'Failed to assign container {self._name} since'
48+
' it is not spawned')
49+
50+
return f'http://localhost:{self._ports[self._uuid]}'
51+
52+
def assign_container(self, env: Dict[str, str] = {}):
53+
"""Make a POST request to /admin/instance/assign to specialize the
54+
container
55+
"""
56+
url = f'http://localhost:{self._ports[self._uuid]}'
57+
58+
# Add compulsory fields in specialization context
59+
env["FUNCTIONS_EXTENSION_VERSION"] = f"~{self._host_version}"
60+
env["FUNCTIONS_WORKER_RUNTIME"] = "python"
61+
env["FUNCTIONS_WORKER_RUNTIME_VERSION"] = self._py_version
62+
env["WEBSITE_SITE_NAME"] = self._uuid
63+
env["WEBSITE_HOSTNAME"] = f"{self._uuid}.azurewebsites.com"
64+
65+
# Send the specialization context via a POST request
66+
req = requests.Request(
67+
method="POST",
68+
url=f"{url}/admin/instance/assign",
69+
data=json.dumps({
70+
"encryptedContext": self._get_site_encrypted_context(
71+
self._uuid, env
72+
)
73+
})
74+
)
75+
response = self.send_request(req)
76+
if not response.ok:
77+
stdout = self.get_container_logs()
78+
raise RuntimeError(f'Failed to specialize container {self._uuid}'
79+
f' at {url} (status {response.status_code}).'
80+
f' stdout: {stdout}')
81+
82+
def send_request(
83+
self,
84+
req: requests.Request,
85+
ses: requests.Session = None
86+
) -> requests.Response:
87+
"""Send a request with authorization token. Return a Response object"""
88+
session = ses
89+
if session is None:
90+
session = requests.Session()
91+
92+
prepped = session.prepare_request(req)
93+
prepped.headers['Content-Type'] = 'application/json'
94+
prepped.headers['x-ms-site-restricted-token'] = (
95+
self._get_site_restricted_token()
96+
)
97+
prepped.headers['x-site-deployment-id'] = self._uuid
98+
99+
resp = session.send(prepped)
100+
return resp
101+
102+
@classmethod
103+
def _find_latest_mesh_image(cls,
104+
host_major: str,
105+
python_version: str) -> str:
106+
"""Find the latest image in https://mcr.microsoft.com/v2/
107+
azure-functions/mesh/tags/list. Match either (3.1.3, or 3.1.3-python3.x)
108+
"""
109+
if host_major in cls._mesh_images:
110+
return cls._mesh_images[host_major]
111+
112+
# match 3.1.3
113+
regex = re.compile(host_major + r'.\d+.\d+')
114+
115+
# match 3.1.3-python3.x
116+
if python_version != '3.6':
117+
regex = re.compile(host_major + r'.\d+.\d+-python' + python_version)
118+
119+
response = requests.get(_MESH_IMAGE_URL, allow_redirects=True)
120+
if not response.ok:
121+
raise RuntimeError(f'Failed to query latest image for v{host_major}'
122+
f' Python {python_version}.'
123+
f' Status {response.status_code}')
124+
125+
tag_list = response.json().get('tags', [])
126+
version = list(filter(regex.match, tag_list))[-1]
127+
128+
image_tag = f'{_MESH_IMAGE_REPO}:{version}'
129+
cls._mesh_images[host_major] = image_tag
130+
return image_tag
131+
132+
def spawn_container(self,
133+
image: str,
134+
env: Dict[str, str] = {}) -> int:
135+
"""Create a docker container and record its port. Create a docker
136+
container according to the image name. Return the port of container.
137+
"""
138+
# Construct environment variables and start the docker container
139+
worker_path = os.path.dirname(__file__)
140+
container_worker_path = (
141+
f"/azure-functions-host/workers/python/{self._py_version}/"
142+
"LINUX/X64/azure_functions_worker"
143+
)
144+
145+
run_cmd = []
146+
run_cmd.extend([self._docker_cmd, "run", "-p", "0:80", "-d"])
147+
run_cmd.extend(["--name", self._uuid, "--privileged"])
148+
run_cmd.extend(["--cap-add", "SYS_ADMIN"])
149+
run_cmd.extend(["--device", "/dev/fuse"])
150+
run_cmd.extend(["-e", f"CONTAINER_NAME={self._uuid}"])
151+
run_cmd.extend(["-e", f"CONTAINER_ENCRYPTION_KEY={_DUMMY_CONT_KEY}"])
152+
run_cmd.extend(["-e", "WEBSITE_PLACEHOLDER_MODE=1"])
153+
run_cmd.extend(["-v", f'{worker_path}:{container_worker_path}'])
154+
155+
for key, value in env.items():
156+
run_cmd.extend(["-e", f"{key}={value}"])
157+
run_cmd.append(image)
158+
159+
run_process = subprocess.run(args=run_cmd,
160+
stdout=subprocess.PIPE,
161+
stderr=subprocess.PIPE)
162+
if run_process.returncode != 0:
163+
raise RuntimeError('Failed to spawn docker container for'
164+
f' {image} with uuid {self._uuid}.'
165+
f' stderr: {run_process.stderr}')
166+
167+
# Wait for three seconds for the port to expose
168+
time.sleep(3)
169+
170+
# Acquire the port number of the container
171+
port_cmd = [self._docker_cmd, "port", self._uuid]
172+
port_process = subprocess.run(args=port_cmd,
173+
stdout=subprocess.PIPE,
174+
stderr=subprocess.PIPE)
175+
if port_process.returncode != 0:
176+
raise RuntimeError(f'Failed to acquire port for {self._uuid}.'
177+
f' stderr: {port_process.stderr}')
178+
port_number = port_process.stdout.decode().strip('\n').split(':')[-1]
179+
180+
# Register port number onto the table
181+
self._ports[self._uuid] = port_number
182+
183+
# Wait for three seconds for the container to be in ready state
184+
time.sleep(3)
185+
return port_number
186+
187+
def get_container_logs(self) -> str:
188+
"""Get container logs, the first element in tuple is stdout and the
189+
second element is stderr
190+
"""
191+
get_logs_cmd = [self._docker_cmd, "logs", self._uuid]
192+
get_logs_process = subprocess.run(args=get_logs_cmd,
193+
stdout=subprocess.PIPE,
194+
stderr=subprocess.PIPE)
195+
196+
# The `docker logs` command will merge stdout and stderr into stdout
197+
return get_logs_process.stdout.decode('utf-8')
198+
199+
def safe_kill_container(self) -> bool:
200+
"""Kill a container by its name. Returns True on success.
201+
"""
202+
kill_cmd = [self._docker_cmd, "rm", "-f", self._uuid]
203+
kill_process = subprocess.run(args=kill_cmd, stdout=subprocess.DEVNULL)
204+
exit_code = kill_process.returncode
205+
206+
if self._uuid in self._ports:
207+
del self._ports[self._uuid]
208+
return exit_code == 0
209+
210+
@classmethod
211+
def _get_site_restricted_token(cls) -> str:
212+
"""Get the header value which can be used by x-ms-site-restricted-token
213+
which expires in one day.
214+
"""
215+
exp_ns = int(time.time() + 24 * 60 * 60) * 1000000000
216+
return cls._encrypt_context(_DUMMY_CONT_KEY, f'exp={exp_ns}')
217+
218+
@classmethod
219+
def _get_site_encrypted_context(cls,
220+
site_name: str,
221+
env: Dict[str, str]) -> str:
222+
"""Get the encrypted context for placeholder mode specialization"""
223+
ctx = {
224+
"SiteId": 1,
225+
"SiteName": site_name,
226+
"Environment": env
227+
}
228+
229+
# Ensure WEBSITE_SITE_NAME is set to simulate production mode
230+
ctx["Environment"]["WEBSITE_SITE_NAME"] = site_name
231+
return cls._encrypt_context(_DUMMY_CONT_KEY, json.dumps(ctx))
232+
233+
@classmethod
234+
def _encrypt_context(cls, encryption_key: str, plain_text: str) -> str:
235+
"""Encrypt plain text context into a encrypted message which can
236+
be accepted by the host
237+
"""
238+
encryption_key_bytes = base64.b64decode(encryption_key.encode())
239+
plain_text_bytes = pad(plain_text.encode(), 16)
240+
iv_bytes = '0123456789abcedf'.encode()
241+
242+
# Start encryption
243+
cipher = AES.new(encryption_key_bytes, AES.MODE_CBC, iv=iv_bytes)
244+
encrypted_bytes = cipher.encrypt(plain_text_bytes)
245+
246+
# Prepare final result
247+
iv_base64 = base64.b64encode(iv_bytes).decode()
248+
encrypted_base64 = base64.b64encode(encrypted_bytes).decode()
249+
key_sha256 = SHA256Hash(encryption_key_bytes).digest()
250+
key_sha256_base64 = base64.b64encode(key_sha256).decode()
251+
return f'{iv_base64}.{encrypted_base64}.{key_sha256_base64}'
252+
253+
def __enter__(self):
254+
mesh_image = self._find_latest_mesh_image(self._host_version,
255+
self._py_version)
256+
self.spawn_container(image=mesh_image)
257+
return self
258+
259+
def __exit__(self, exc_type, exc_value, traceback):
260+
logs = self.get_container_logs()
261+
self.safe_kill_container()
262+
263+
if traceback:
264+
print(f'Test failed with container logs: {logs}',
265+
file=sys.stderr,
266+
flush=True)

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ def run(self):
396396
'azure-functions==1.7.2',
397397
'azure-eventhub~=5.1.0',
398398
'python-dateutil~=2.8.1',
399+
'pycryptodome~=3.10.1',
399400
'flake8~=3.7.9',
400401
'mypy',
401402
'pytest',

0 commit comments

Comments
 (0)