-
Notifications
You must be signed in to change notification settings - Fork 89
/
Copy pathhermit_test_runner.py
executable file
·281 lines (249 loc) · 12.6 KB
/
hermit_test_runner.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
#!/usr/bin/env python3
import argparse
import multiprocessing
import os
import os.path
import platform
import subprocess
import sys
import time
from subprocess import PIPE
class TestRunner:
""" TestRunner class. Provides methods for running the test and validating test success.
Subclassed by QemuTestRunner and UhyveTestRunner that extend this class
"""
def __init__(self,
test_command,
timeout_seconds: int,
num_cores=1,
memory_in_megabyte=512,
gdb_enabled=False,
verbose=False):
online_cpus = multiprocessing.cpu_count()
if num_cores > online_cpus:
print("WARNING: You specified num_cores={}, however only {} cpu cores are available."
" Setting num_cores to {}".format(num_cores, online_cpus, online_cpus), file=sys.stderr)
num_cores = online_cpus
self.num_cores: int = num_cores
self.memory_MB: int = memory_in_megabyte
self.gdb_enabled: bool = gdb_enabled
self.gdb_port = None
self.verbose: bool = verbose
self.test_command = test_command
self.custom_env = None
self.timeout: int = timeout_seconds
def validate_test_success(self, rc, stdout, stderr, execution_time) -> bool:
"""
:param rc: TestRunner ignores rc, but subclasses should evaluate the rc
:param stdout:
:param stderr: ToDo: Not sure if we actually need this, does hermit use this?
:param execution_time:
:return: bool - true indicates success
"""
# ToDo: possibly add test failure due to excessive execution time?
# This could be done if a test suddenly regresses compared to usual execution time
# Probably need criterion + stable execution environment for this
if not validate_stdout(stdout):
print("Test failed due to Panic. Dumping output (stderr):\n{}\n\n"
"Dumping stdout:\n{}\nFinished Dump".format(stderr, stdout), file=sys.stderr)
return False
else:
return True
def run_test(self):
"""
:return: returncode, stdout, stderr, elapsed_time, timed_out: bool
"""
print("Calling {}".format(type(self).__name__))
try:
start_time = time.perf_counter() # https://docs.python.org/3/library/time.html#time.perf_counter
if self.custom_env is None:
p = subprocess.run(self.test_command, stdout=PIPE, stderr=PIPE, universal_newlines=True,
timeout=self.timeout)
else:
p = subprocess.run(self.test_command, stdout=PIPE, stderr=PIPE, universal_newlines=True,
timeout=self.timeout, env=self.custom_env)
end_time = time.perf_counter()
elapsed_time = end_time - start_time
except subprocess.TimeoutExpired as e:
elapsed_time = self.timeout * (10 ** 9)
return None, e.stdout, e.stderr, elapsed_time, True
# ToDo: add some timeout
return p.returncode, p.stdout, p.stderr, elapsed_time, False
class QemuTestRunner(TestRunner):
"""
Test Runner for QEMU. Requires a path to the bootloader and the test_exe.
Verbose is not an option, so the '-vv' flag for `hermit_test_runner.py` behaves the same as `-v`.
"""
def __init__(self,
test_exe_path: str,
timeout_seconds: int,
bootloader_path: str = '../loader/target/x86_64/debug/hermit-loader',
num_cores=1,
memory_in_megabyte=512,
gdb_enabled=False):
assert os.path.isfile(test_exe_path), "Invalid path to test executable: {}".format(test_exe_path)
assert os.path.isfile(bootloader_path), "Invalid bootloader path: {}".format(bootloader_path)
self.bootloader_path = os.path.abspath(bootloader_path)
test_command = ['qemu-system-x86_64',
'-display', 'none',
'-smp', str(num_cores),
'-m', str(memory_in_megabyte) + 'M',
'-serial', 'stdio',
'-kernel', bootloader_path,
'-initrd', test_exe_path,
'-cpu', 'qemu64,apic,fsgsbase,rdtscp,xsave,xsaveopt,fxsr',
'-device', 'isa-debug-exit,iobase=0xf4,iosize=0x04',
]
super().__init__(test_command,
timeout_seconds=timeout_seconds,
num_cores = num_cores,
memory_in_megabyte = memory_in_megabyte,
gdb_enabled = gdb_enabled,
verbose=False
)
if self.gdb_enabled:
self.gdb_port = 1234
self.test_command.append('-s')
self.test_command.append('-S')
print('Testing with Gdb enabled at port {}'.format(self.gdb_port))
def validate_test_success(self, rc, stdout, stderr, execution_time) -> bool:
assert rc != 0, "Error: rc is zero, something changed regarding the returncodes from qemu"
if rc == 1:
print("Test failed due to QEMU error. Is QEMU installed?", file=sys.stderr)
return False
elif rc != 3:
# Since we are using asserts, tests should mostly fail due to a panic
# However, other kinds of test errors using the debug_exit of qemu are also possible
print("Test failed due to error returncode: {}".format(rc), file=sys.stderr)
return False
return super().validate_test_success(rc, stdout, stderr, execution_time)
class UhyveTestRunner(TestRunner):
def __init__(self,
test_exe_path: str,
timeout_seconds: int,
uhyve_path=None,
num_cores=1,
memory_in_megabyte=512,
gdb_enabled=False,
verbose=False):
if platform.system() == 'Windows':
print("Error: using uhyve requires kvm. Please use Linux or Mac OS", file=sys.stderr)
raise OSError
if uhyve_path is None:
uhyve_path = 'uhyve'
else:
assert os.path.isfile(uhyve_path), "Invalid uhyve path"
self.uhyve_path = os.path.abspath(uhyve_path)
test_command = [uhyve_path]
if verbose:
test_command.append('-v')
test_command.append(test_exe_path)
super().__init__(test_command=test_command, timeout_seconds=timeout_seconds, num_cores=num_cores, memory_in_megabyte=memory_in_megabyte,
gdb_enabled=gdb_enabled, verbose=verbose)
# ToDo: This could be done a lot nicer if we could use flags to pass these options to uhyve
if gdb_enabled or num_cores != 1:
self.custom_env = os.environ.copy()
if gdb_enabled:
self.gdb_port = 1234 # ToDo: Add parameter to customize this
self.custom_env['HERMIT_GDB_PORT'] = str(self.gdb_port)
print('Testing with Gdb enabled at port {}'.format(self.gdb_port))
if num_cores != 1:
self.custom_env['HERMIT_CPUS'] = str(num_cores)
def validate_test_success(self, rc, stdout, stderr, execution_time) -> bool:
if rc != 0:
print("Test failed due to error returncode: {}".format(rc), file=sys.stderr)
return False
else:
return super().validate_test_success(rc, stdout, stderr, execution_time)
# ToDo: Think about how to pass information about how many tests an executable executed back to the runner
# Maybe something like `[TEST_INFO]` at the start of a line?
def validate_stdout(stdout):
"""
:param stdout:
:return: true if stdout does not indicate test failure
"""
# Todo: support should_panic tests (Implementation on hermit side with custom panic handler)
if "!!!PANIC!!!" in stdout:
return False
return True
def clean_test_name(name: str):
if name.endswith('.exe'):
name = name.replace('.exe', '')
# Remove the hash from the name
parts = name.split('-')
if len(parts) > 1:
try:
_hex = int(parts[-1], base=16) # Test if last element is hex hash
clean_name = "-".join(parts[:-1]) # Rejoin with '-' as separator in case test has it in filename
except ValueError as e:
print(e)
clean_name = name # In this case name doesn't contain a hash, so don't modify it any further
return clean_name
return name
# Start "main"
assert sys.version_info[0] == 3, "Python 3 is required to run this script"
assert sys.version_info[1] >= 6, "Currently at least Python 3.6 is required for this script"
parser = argparse.ArgumentParser(description='See documentation of cargo test runner for custom test framework')
hypervisor_group = parser.add_mutually_exclusive_group()
hypervisor_group.add_argument('--bootloader_path', type=str, help="Provide path to hermit bootloader, implicitly "
"switches to QEMU execution")
hypervisor_group.add_argument('--uhyve_path', type=str, default=None, help="Custom Path to uhyve if it is not in PATH")
parser.add_argument('runner_args', type=str, nargs='*')
parser.add_argument('-v', '--verbose', action='store_true', help="Always prints stdout/stderr of test")
parser.add_argument('-vv', '--veryverbose', action='store_true', help='verbose and additionally runs test verbosely')
parser.add_argument('--gdb', action='store_true', help='Enables gdb on port 1234 and stops at test executable '
'entrypoint')
parser.add_argument('--num_cores', type=int, default=1, help="Number of CPU cores the test should run on")
parser.add_argument('--timeout', type=int, default=300, help="Timeout in seconds for the test process.")
args = parser.parse_args()
print("Arguments: {}".format(args.runner_args))
# The last argument is the executable, all other arguments are ignored for now - ToDo: recheck this
test_exe = args.runner_args[-1]
assert isinstance(test_exe, str)
assert os.path.isfile(test_exe) # If this fails likely something about runner args changed
assert args.timeout > 0, "Timeout must be a positive integer" # Todo: add range checking directly into parser.add_argument
# ToDo: Add additional test based arguments for qemu / uhyve
test_name = os.path.basename(test_exe)
test_name = clean_test_name(test_name)
if args.bootloader_path is not None:
test_runner = QemuTestRunner(test_exe,
timeout_seconds=args.timeout,
bootloader_path = args.bootloader_path,
gdb_enabled=args.gdb,
num_cores=args.num_cores
)
elif platform.system() == 'Windows':
print("Error: using uhyve requires kvm. Please use Linux or Mac OS, or use qemu", file=sys.stderr)
exit(-1)
else:
test_runner = UhyveTestRunner(test_exe,
timeout_seconds=args.timeout,
verbose=args.veryverbose,
gdb_enabled=args.gdb,
num_cores=args.num_cores,
uhyve_path=args.uhyve_path)
if test_name == "hermit":
print("Executing the Unittests is currently broken... Skipping Test NOT marking as failed")
# print("Note: If you want to execute all tests, consider adding the '--no-fail-fast' flag")
print("If you wish to manually execute the Unittests, you can simply run:")
print("`{}`".format(' '.join(test_runner.test_command)))
exit(0)
rc, stdout, stderr, execution_time, timed_out = test_runner.run_test()
if timed_out:
print('Test {} did not finish before timeout of {} seconds'.format(test_name, args.timeout))
print("Test failed - Dumping Stderr:\n{}\n\nDumping Stdout:\n{}\n".format(stderr, stdout), file=sys.stderr)
exit(1)
test_ok = test_runner.validate_test_success(rc, stdout, stderr, execution_time)
if test_ok :
print("Test Ok: {} - runtime: {} seconds".format(test_name, execution_time))
if args.verbose or args.veryverbose:
print("Test {} stdout: {}".format(test_name, stdout))
print("Test {} stderr: {}".format(test_name, stderr))
exit(0)
else:
print("Test failed: {} - runtime: {} seconds".format(test_name, execution_time / (10 ** 9)))
print("Test failed - Dumping Stderr:\n{}\n\nDumping Stdout:\n{}\n".format(stderr, stdout), file=sys.stderr)
exit(1)
# Todo: improve information about the test:
# Maybe we could produce a JUnit XML by iteratively generating it for every call of this script
# Sounds complex though