Skip to content

Commit 3794edc

Browse files
IMDS Mock Server Testing (#1108)
* Python 3.5 compat for fake_azure * proc-ctl controls child processes * Reorder test cases to behave better with Bottle * Defining CTest fixtures, including a fake IMDS server
1 parent 313a5f6 commit 3794edc

File tree

7 files changed

+400
-39
lines changed

7 files changed

+400
-39
lines changed

CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,9 @@ include (MakeDistFiles)
223223

224224
# Enable CTest
225225
include (CTest)
226+
if (BUILD_TESTING)
227+
include (TestFixtures)
228+
endif ()
226229

227230
# Ensure the default behavior: don't ignore RPATH settings.
228231
set (CMAKE_SKIP_BUILD_RPATH OFF)

build/cmake/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ set (build_cmake_MODULES
1515
Sanitizers.cmake
1616
CCache.cmake
1717
LLDLinker.cmake
18+
TestFixtures.cmake
1819
)
1920

2021
set_local_dist (build_cmake_DIST_local

build/cmake/LoadTests.cmake

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ endif ()
2626
# Split lines on newlines
2727
string (REPLACE "\n" ";" lines "${tests_out}")
2828

29+
# TODO: Allow individual test cases to specify the fixtures they want.
30+
set (all_fixtures "mongoc/fixtures/fake_imds")
31+
set (all_env
32+
MCD_TEST_AZURE_IMDS_HOST=localhost:14987 # Refer: Fixtures.cmake
33+
)
34+
2935
# Generate the test definitions
3036
foreach (line IN LISTS lines)
3137
if (NOT line MATCHES "^/")
@@ -44,5 +50,7 @@ foreach (line IN LISTS lines)
4450
SKIP_REGULAR_EXPRESSION "@@ctest-skipped@@"
4551
# 45 seconds of timeout on each test.
4652
TIMEOUT 45
53+
FIXTURES_REQUIRED "${all_fixtures}"
54+
ENVIRONMENT "${all_env}"
4755
)
4856
endforeach ()

build/cmake/TestFixtures.cmake

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
find_package (Python3 COMPONENTS Interpreter)
2+
3+
if (NOT TARGET Python3::Interpreter)
4+
message (STATUS "Python3 was not found, so test fixtures will not be defined")
5+
return ()
6+
endif ()
7+
8+
get_filename_component(_MONGOC_BUILD_SCRIPT_DIR "${CMAKE_CURRENT_LIST_DIR}" DIRECTORY)
9+
set (_MONGOC_PROC_CTL_COMMAND "$<TARGET_FILE:Python3::Interpreter>" -u -- "${_MONGOC_BUILD_SCRIPT_DIR}/proc-ctl.py")
10+
11+
12+
function (mongo_define_subprocess_fixture name)
13+
cmake_parse_arguments(PARSE_ARGV 1 ARG "" "SPAWN_WAIT;STOP_WAIT;WORKING_DIRECTORY" "COMMAND")
14+
string (MAKE_C_IDENTIFIER ident "${name}")
15+
if (NOT ARG_SPAWN_WAIT)
16+
set (ARG_SPAWN_WAIT 1)
17+
endif ()
18+
if (NOT ARG_STOP_WAIT)
19+
set (ARG_STOP_WAIT 5)
20+
endif ()
21+
if (NOT ARG_WORKING_DIRECTORY)
22+
set (ARG_WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}")
23+
endif ()
24+
if (NOT ARG_COMMAND)
25+
message (SEND_ERROR "mongo_define_subprocess_fixture(${name}) requires a COMMAND")
26+
return ()
27+
endif ()
28+
get_filename_component (ctl_dir "${CMAKE_CURRENT_BINARY_DIR}/${ident}.ctl" ABSOLUTE)
29+
add_test (NAME "${name}/start"
30+
COMMAND ${_MONGOC_PROC_CTL_COMMAND} start
31+
"--ctl-dir=${ctl_dir}"
32+
"--cwd=${ARG_WORKING_DIRECTORY}"
33+
"--spawn-wait=${ARG_SPAWN_WAIT}"
34+
-- ${ARG_COMMAND})
35+
add_test (NAME "${name}/stop"
36+
COMMAND ${_MONGOC_PROC_CTL_COMMAND} stop "--ctl-dir=${ctl_dir}" --if-not-running=ignore)
37+
set_property (TEST "${name}/start" PROPERTY FIXTURES_SETUP "${name}")
38+
set_property (TEST "${name}/stop" PROPERTY FIXTURES_CLEANUP "${name}")
39+
endfunction ()
40+
41+
# Create a fixture that runs a fake Azure IMDS server
42+
mongo_define_subprocess_fixture(
43+
mongoc/fixtures/fake_imds
44+
SPAWN_WAIT 0.2
45+
COMMAND
46+
"$<TARGET_FILE:Python3::Interpreter>" -u --
47+
"${_MONGOC_BUILD_SCRIPT_DIR}/bottle.py" fake_azure:imds
48+
--bind localhost:14987 # Port 14987 chosen arbitrarily
49+
)

build/fake_azure.py

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
from __future__ import annotations
2-
1+
import functools
2+
import json
33
import sys
44
import time
5-
import json
65
import traceback
7-
import functools
6+
from pathlib import Path
7+
88
import bottle
9-
from bottle import Bottle, HTTPResponse, request
9+
from bottle import Bottle, HTTPResponse
1010

1111
imds = Bottle(autojson=True)
1212
"""An Azure IMDS server"""
1313

14-
from typing import TYPE_CHECKING, Any, Callable, Iterable, overload
14+
from typing import TYPE_CHECKING, Any, Callable, Iterable, cast, overload
1515

16-
if TYPE_CHECKING:
16+
if not TYPE_CHECKING:
17+
from bottle import request
18+
else:
1719
from typing import Protocol
1820

1921
class _RequestParams(Protocol):
@@ -22,7 +24,7 @@ def __getitem__(self, key: str) -> str:
2224
...
2325

2426
@overload
25-
def get(self, key: str) -> str | None:
27+
def get(self, key: str) -> 'str | None':
2628
...
2729

2830
@overload
@@ -31,25 +33,30 @@ def get(self, key: str, default: str) -> str:
3133

3234
class _HeadersDict(dict[str, str]):
3335

34-
def raw(self, key: str) -> bytes | None:
36+
def raw(self, key: str) -> 'bytes | None':
3537
...
3638

3739
class _Request(Protocol):
38-
query: _RequestParams
39-
params: _RequestParams
40-
headers: _HeadersDict
4140

42-
request: _Request
41+
@property
42+
def query(self) -> _RequestParams:
43+
...
4344

45+
@property
46+
def params(self) -> _RequestParams:
47+
...
4448

45-
def parse_qs(qs: str) -> dict[str, str]:
46-
return dict(bottle._parse_qsl(qs)) # type: ignore
49+
@property
50+
def headers(self) -> _HeadersDict:
51+
...
4752

53+
request = cast('_Request', None)
4854

49-
def require(cond: bool, message: str):
50-
if not cond:
51-
print(f'REQUIREMENT FAILED: {message}')
52-
raise bottle.HTTPError(400, message)
55+
56+
def parse_qs(qs: str) -> 'dict[str, str]':
57+
# Re-use the bottle.py query string parser. It's a private function, but
58+
# we're using a fixed version of Bottle.
59+
return dict(bottle._parse_qsl(qs)) # type: ignore
5360

5461

5562
_HandlerFuncT = Callable[
@@ -58,6 +65,7 @@ def require(cond: bool, message: str):
5865

5966

6067
def handle_asserts(fn: _HandlerFuncT) -> _HandlerFuncT:
68+
"Convert assertion failures into HTTP 400s"
6169

6270
@functools.wraps(fn)
6371
def wrapped():
@@ -72,17 +80,10 @@ def wrapped():
7280
return wrapped
7381

7482

75-
def test_flags() -> dict[str, str]:
83+
def test_params() -> 'dict[str, str]':
7684
return parse_qs(request.headers.get('X-MongoDB-HTTP-TestParams', ''))
7785

7886

79-
def maybe_pause():
80-
pause = int(test_flags().get('pause', '0'))
81-
if pause:
82-
print(f'Pausing for {pause} seconds')
83-
time.sleep(pause)
84-
85-
8687
@imds.get('/metadata/identity/oauth2/token')
8788
@handle_asserts
8889
def get_oauth2_token():
@@ -91,10 +92,7 @@ def get_oauth2_token():
9192
resource = request.query['resource']
9293
assert resource == 'https://vault.azure.net', 'Only https://vault.azure.net is supported'
9394

94-
flags = test_flags()
95-
maybe_pause()
96-
97-
case = flags.get('case')
95+
case = test_params().get('case')
9896
print('Case is:', case)
9997
if case == '404':
10098
return HTTPResponse(status=404)
@@ -114,17 +112,18 @@ def get_oauth2_token():
114112
if case == 'slow':
115113
return _slow()
116114

117-
assert case is None or case == '', f'Unknown HTTP test case "{case}"'
115+
assert case in (None, ''), 'Unknown HTTP test case "{}"'.format(case)
118116

119117
return {
120118
'access_token': 'magic-cookie',
121-
'expires_in': '60',
119+
'expires_in': '70',
122120
'token_type': 'Bearer',
123121
'resource': 'https://vault.azure.net',
124122
}
125123

126124

127125
def _gen_giant() -> Iterable[bytes]:
126+
"Generate a giant message"
128127
yield b'{ "item": ['
129128
for _ in range(1024 * 256):
130129
yield (b'null, null, null, null, null, null, null, null, null, null, '
@@ -136,6 +135,7 @@ def _gen_giant() -> Iterable[bytes]:
136135

137136

138137
def _slow() -> Iterable[bytes]:
138+
"Generate a very slow message"
139139
yield b'{ "item": ['
140140
for _ in range(1000):
141141
yield b'null, '
@@ -144,6 +144,8 @@ def _slow() -> Iterable[bytes]:
144144

145145

146146
if __name__ == '__main__':
147-
print(f'RECOMMENDED: Run this script using bottle.py in the same '
148-
f'directory (e.g. [{sys.executable} bottle.py fake_azure:imds])')
147+
print(
148+
'RECOMMENDED: Run this script using bottle.py (e.g. [{} {}/bottle.py fake_azure:imds])'
149+
.format(sys.executable,
150+
Path(__file__).resolve().parent))
149151
imds.run()

0 commit comments

Comments
 (0)