Commit | Line | Data |
---|---|---|
fb551941 VF |
1 | #!/usr/bin/env python3 |
2 | """ | |
3 | fiotestlib.py | |
4 | ||
5 | This library contains FioTest objects that provide convenient means to run | |
6 | different sorts of fio tests. | |
7 | ||
8 | It also contains a test runner that runs an array of dictionary objects | |
9 | describing fio tests. | |
10 | """ | |
11 | ||
12 | import os | |
13 | import sys | |
14 | import json | |
15 | import locale | |
16 | import logging | |
17 | import platform | |
18 | import traceback | |
19 | import subprocess | |
20 | from pathlib import Path | |
ecd6d30f | 21 | from fiotestcommon import get_file |
fb551941 VF |
22 | |
23 | ||
24 | class FioTest(): | |
25 | """Base for all fio tests.""" | |
26 | ||
68b3a741 VF |
27 | def __init__(self, exe_path, success): |
28 | self.paths = {'exe': exe_path} | |
fb551941 VF |
29 | self.success = success |
30 | self.output = {} | |
fb551941 | 31 | self.testnum = None |
fb551941 VF |
32 | self.passed = True |
33 | self.failure_reason = '' | |
68b3a741 VF |
34 | self.filenames = {} |
35 | self.parameters = None | |
fb551941 | 36 | |
68b3a741 | 37 | def setup(self, artifact_root, testnum, parameters): |
fb551941 VF |
38 | """Setup instance variables for test.""" |
39 | ||
fb551941 | 40 | self.testnum = testnum |
68b3a741 VF |
41 | self.parameters = parameters |
42 | self.paths['artifacts'] = artifact_root | |
43 | self.paths['test_dir'] = os.path.join(artifact_root, f"{testnum:04d}") | |
44 | if not os.path.exists(self.paths['test_dir']): | |
45 | os.mkdir(self.paths['test_dir']) | |
46 | ||
47 | self.filenames['cmd'] = os.path.join(self.paths['test_dir'], | |
48 | f"{os.path.basename(self.paths['exe'])}.command") | |
49 | self.filenames['stdout'] = os.path.join(self.paths['test_dir'], | |
50 | f"{os.path.basename(self.paths['exe'])}.stdout") | |
51 | self.filenames['stderr'] = os.path.join(self.paths['test_dir'], | |
52 | f"{os.path.basename(self.paths['exe'])}.stderr") | |
53 | self.filenames['exitcode'] = os.path.join(self.paths['test_dir'], | |
54 | f"{os.path.basename(self.paths['exe'])}.exitcode") | |
fb551941 VF |
55 | |
56 | def run(self): | |
57 | """Run the test.""" | |
58 | ||
59 | raise NotImplementedError() | |
60 | ||
61 | def check_result(self): | |
62 | """Check test results.""" | |
63 | ||
64 | raise NotImplementedError() | |
65 | ||
66 | ||
67 | class FioExeTest(FioTest): | |
68 | """Test consists of an executable binary or script""" | |
69 | ||
68b3a741 | 70 | def __init__(self, exe_path, success): |
fb551941 VF |
71 | """Construct a FioExeTest which is a FioTest consisting of an |
72 | executable binary or script. | |
73 | ||
74 | exe_path: location of executable binary or script | |
fb551941 VF |
75 | success: Definition of test success |
76 | """ | |
77 | ||
68b3a741 | 78 | FioTest.__init__(self, exe_path, success) |
fb551941 VF |
79 | |
80 | def run(self): | |
81 | """Execute the binary or script described by this instance.""" | |
82 | ||
68b3a741 | 83 | command = [self.paths['exe']] + self.parameters |
1cf0ba9d VF |
84 | with open(self.filenames['cmd'], "w+", |
85 | encoding=locale.getpreferredencoding()) as command_file: | |
86 | command_file.write(f"{command}\n") | |
87 | ||
fb551941 | 88 | try: |
1cf0ba9d VF |
89 | with open(self.filenames['stdout'], "w+", |
90 | encoding=locale.getpreferredencoding()) as stdout_file, \ | |
91 | open(self.filenames['stderr'], "w+", | |
92 | encoding=locale.getpreferredencoding()) as stderr_file, \ | |
93 | open(self.filenames['exitcode'], "w+", | |
94 | encoding=locale.getpreferredencoding()) as exitcode_file: | |
95 | proc = None | |
96 | # Avoid using subprocess.run() here because when a timeout occurs, | |
97 | # fio will be stopped with SIGKILL. This does not give fio a | |
98 | # chance to clean up and means that child processes may continue | |
99 | # running and submitting IO. | |
100 | proc = subprocess.Popen(command, | |
101 | stdout=stdout_file, | |
102 | stderr=stderr_file, | |
103 | cwd=self.paths['test_dir'], | |
104 | universal_newlines=True) | |
105 | proc.communicate(timeout=self.success['timeout']) | |
106 | exitcode_file.write(f'{proc.returncode}\n') | |
107 | logging.debug("Test %d: return code: %d", self.testnum, proc.returncode) | |
108 | self.output['proc'] = proc | |
fb551941 VF |
109 | except subprocess.TimeoutExpired: |
110 | proc.terminate() | |
111 | proc.communicate() | |
112 | assert proc.poll() | |
113 | self.output['failure'] = 'timeout' | |
114 | except Exception: | |
115 | if proc: | |
116 | if not proc.poll(): | |
117 | proc.terminate() | |
118 | proc.communicate() | |
119 | self.output['failure'] = 'exception' | |
120 | self.output['exc_info'] = sys.exc_info() | |
fb551941 VF |
121 | |
122 | def check_result(self): | |
123 | """Check results of test run.""" | |
124 | ||
125 | if 'proc' not in self.output: | |
126 | if self.output['failure'] == 'timeout': | |
127 | self.failure_reason = f"{self.failure_reason} timeout," | |
128 | else: | |
129 | assert self.output['failure'] == 'exception' | |
130 | self.failure_reason = '{0} exception: {1}, {2}'.format( | |
131 | self.failure_reason, self.output['exc_info'][0], | |
132 | self.output['exc_info'][1]) | |
133 | ||
134 | self.passed = False | |
135 | return | |
136 | ||
137 | if 'zero_return' in self.success: | |
138 | if self.success['zero_return']: | |
139 | if self.output['proc'].returncode != 0: | |
140 | self.passed = False | |
141 | self.failure_reason = f"{self.failure_reason} non-zero return code," | |
142 | else: | |
143 | if self.output['proc'].returncode == 0: | |
144 | self.failure_reason = f"{self.failure_reason} zero return code," | |
145 | self.passed = False | |
146 | ||
68b3a741 | 147 | stderr_size = os.path.getsize(self.filenames['stderr']) |
fb551941 VF |
148 | if 'stderr_empty' in self.success: |
149 | if self.success['stderr_empty']: | |
150 | if stderr_size != 0: | |
151 | self.failure_reason = f"{self.failure_reason} stderr not empty," | |
152 | self.passed = False | |
153 | else: | |
154 | if stderr_size == 0: | |
155 | self.failure_reason = f"{self.failure_reason} stderr empty," | |
156 | self.passed = False | |
157 | ||
158 | ||
0dc6e911 | 159 | class FioJobFileTest(FioExeTest): |
68b3a741 | 160 | """Test consists of a fio job with options in a job file.""" |
fb551941 VF |
161 | |
162 | def __init__(self, fio_path, fio_job, success, fio_pre_job=None, | |
163 | fio_pre_success=None, output_format="normal"): | |
0dc6e911 | 164 | """Construct a FioJobFileTest which is a FioExeTest consisting of a |
fb551941 VF |
165 | single fio job file with an optional setup step. |
166 | ||
167 | fio_path: location of fio executable | |
168 | fio_job: location of fio job file | |
169 | success: Definition of test success | |
170 | fio_pre_job: fio job for preconditioning | |
171 | fio_pre_success: Definition of test success for fio precon job | |
172 | output_format: normal (default), json, jsonplus, or terse | |
173 | """ | |
174 | ||
175 | self.fio_job = fio_job | |
176 | self.fio_pre_job = fio_pre_job | |
177 | self.fio_pre_success = fio_pre_success if fio_pre_success else success | |
178 | self.output_format = output_format | |
179 | self.precon_failed = False | |
180 | self.json_data = None | |
68b3a741 VF |
181 | |
182 | FioExeTest.__init__(self, fio_path, success) | |
183 | ||
184 | def setup(self, artifact_root, testnum, parameters=None): | |
185 | """Setup instance variables for fio job test.""" | |
186 | ||
187 | self.filenames['fio_output'] = f"{os.path.basename(self.fio_job)}.output" | |
188 | fio_args = [ | |
fb551941 VF |
189 | "--max-jobs=16", |
190 | f"--output-format={self.output_format}", | |
68b3a741 | 191 | f"--output={self.filenames['fio_output']}", |
fb551941 VF |
192 | self.fio_job, |
193 | ] | |
fb551941 | 194 | |
68b3a741 | 195 | super().setup(artifact_root, testnum, fio_args) |
fb551941 | 196 | |
68b3a741 VF |
197 | # Update the filenames from the default |
198 | self.filenames['cmd'] = os.path.join(self.paths['test_dir'], | |
199 | f"{os.path.basename(self.fio_job)}.command") | |
200 | self.filenames['stdout'] = os.path.join(self.paths['test_dir'], | |
201 | f"{os.path.basename(self.fio_job)}.stdout") | |
202 | self.filenames['stderr'] = os.path.join(self.paths['test_dir'], | |
203 | f"{os.path.basename(self.fio_job)}.stderr") | |
204 | self.filenames['exitcode'] = os.path.join(self.paths['test_dir'], | |
205 | f"{os.path.basename(self.fio_job)}.exitcode") | |
fb551941 VF |
206 | |
207 | def run_pre_job(self): | |
208 | """Run fio job precondition step.""" | |
209 | ||
68b3a741 | 210 | precon = FioJobFileTest(self.paths['exe'], self.fio_pre_job, |
fb551941 VF |
211 | self.fio_pre_success, |
212 | output_format=self.output_format) | |
68b3a741 | 213 | precon.setup(self.paths['artifacts'], self.testnum) |
fb551941 VF |
214 | precon.run() |
215 | precon.check_result() | |
216 | self.precon_failed = not precon.passed | |
217 | self.failure_reason = precon.failure_reason | |
218 | ||
219 | def run(self): | |
220 | """Run fio job test.""" | |
221 | ||
222 | if self.fio_pre_job: | |
223 | self.run_pre_job() | |
224 | ||
225 | if not self.precon_failed: | |
226 | super().run() | |
227 | else: | |
228 | logging.debug("Test %d: precondition step failed", self.testnum) | |
229 | ||
fb551941 VF |
230 | def get_file_fail(self, filename): |
231 | """Safely read a file and fail the test upon error.""" | |
232 | file_data = None | |
233 | ||
234 | try: | |
235 | with open(filename, "r", encoding=locale.getpreferredencoding()) as output_file: | |
236 | file_data = output_file.read() | |
237 | except OSError: | |
238 | self.failure_reason += f" unable to read file {filename}" | |
239 | self.passed = False | |
240 | ||
241 | return file_data | |
242 | ||
243 | def check_result(self): | |
244 | """Check fio job results.""" | |
245 | ||
246 | if self.precon_failed: | |
247 | self.passed = False | |
248 | self.failure_reason = f"{self.failure_reason} precondition step failed," | |
249 | return | |
250 | ||
251 | super().check_result() | |
252 | ||
253 | if not self.passed: | |
254 | return | |
255 | ||
256 | if 'json' not in self.output_format: | |
257 | return | |
258 | ||
68b3a741 VF |
259 | file_data = self.get_file_fail(os.path.join(self.paths['test_dir'], |
260 | self.filenames['fio_output'])) | |
fb551941 VF |
261 | if not file_data: |
262 | return | |
263 | ||
264 | # | |
265 | # Sometimes fio informational messages are included at the top of the | |
266 | # JSON output, especially under Windows. Try to decode output as JSON | |
267 | # data, skipping everything until the first { | |
268 | # | |
269 | lines = file_data.splitlines() | |
270 | file_data = '\n'.join(lines[lines.index("{"):]) | |
271 | try: | |
272 | self.json_data = json.loads(file_data) | |
273 | except json.JSONDecodeError: | |
274 | self.failure_reason = f"{self.failure_reason} unable to decode JSON data," | |
275 | self.passed = False | |
276 | ||
277 | ||
278 | def run_fio_tests(test_list, test_env, args): | |
279 | """ | |
280 | Run tests as specified in test_list. | |
281 | """ | |
282 | ||
283 | passed = 0 | |
284 | failed = 0 | |
285 | skipped = 0 | |
286 | ||
287 | for config in test_list: | |
288 | if (args.skip and config['test_id'] in args.skip) or \ | |
289 | (args.run_only and config['test_id'] not in args.run_only): | |
290 | skipped = skipped + 1 | |
291 | print(f"Test {config['test_id']} SKIPPED (User request)") | |
292 | continue | |
293 | ||
0dc6e911 | 294 | if issubclass(config['test_class'], FioJobFileTest): |
fb551941 VF |
295 | if config['pre_job']: |
296 | fio_pre_job = os.path.join(test_env['fio_root'], 't', 'jobs', | |
297 | config['pre_job']) | |
298 | else: | |
299 | fio_pre_job = None | |
300 | if config['pre_success']: | |
301 | fio_pre_success = config['pre_success'] | |
302 | else: | |
303 | fio_pre_success = None | |
304 | if 'output_format' in config: | |
305 | output_format = config['output_format'] | |
306 | else: | |
307 | output_format = 'normal' | |
308 | test = config['test_class']( | |
309 | test_env['fio_path'], | |
310 | os.path.join(test_env['fio_root'], 't', 'jobs', config['job']), | |
311 | config['success'], | |
312 | fio_pre_job=fio_pre_job, | |
313 | fio_pre_success=fio_pre_success, | |
314 | output_format=output_format) | |
315 | desc = config['job'] | |
68b3a741 | 316 | parameters = [] |
fb551941 VF |
317 | elif issubclass(config['test_class'], FioExeTest): |
318 | exe_path = os.path.join(test_env['fio_root'], config['exe']) | |
68b3a741 | 319 | parameters = [] |
fb551941 VF |
320 | if config['parameters']: |
321 | parameters = [p.format(fio_path=test_env['fio_path'], nvmecdev=args.nvmecdev) | |
322 | for p in config['parameters']] | |
fb551941 VF |
323 | if Path(exe_path).suffix == '.py' and platform.system() == "Windows": |
324 | parameters.insert(0, exe_path) | |
325 | exe_path = "python.exe" | |
326 | if config['test_id'] in test_env['pass_through']: | |
327 | parameters += test_env['pass_through'][config['test_id']].split() | |
68b3a741 | 328 | test = config['test_class'](exe_path, config['success']) |
fb551941 VF |
329 | desc = config['exe'] |
330 | else: | |
331 | print(f"Test {config['test_id']} FAILED: unable to process test config") | |
332 | failed = failed + 1 | |
333 | continue | |
334 | ||
335 | if not args.skip_req: | |
336 | reqs_met = True | |
337 | for req in config['requirements']: | |
338 | reqs_met, reason = req() | |
339 | logging.debug("Test %d: Requirement '%s' met? %s", config['test_id'], reason, | |
340 | reqs_met) | |
341 | if not reqs_met: | |
342 | break | |
343 | if not reqs_met: | |
344 | print(f"Test {config['test_id']} SKIPPED ({reason}) {desc}") | |
345 | skipped = skipped + 1 | |
346 | continue | |
347 | ||
348 | try: | |
68b3a741 | 349 | test.setup(test_env['artifact_root'], config['test_id'], parameters) |
fb551941 VF |
350 | test.run() |
351 | test.check_result() | |
352 | except KeyboardInterrupt: | |
353 | break | |
354 | except Exception as e: | |
355 | test.passed = False | |
356 | test.failure_reason += str(e) | |
357 | logging.debug("Test %d exception:\n%s\n", config['test_id'], traceback.format_exc()) | |
358 | if test.passed: | |
359 | result = "PASSED" | |
360 | passed = passed + 1 | |
361 | else: | |
362 | result = f"FAILED: {test.failure_reason}" | |
363 | failed = failed + 1 | |
68b3a741 | 364 | contents, _ = get_file(test.filenames['stderr']) |
fb551941 | 365 | logging.debug("Test %d: stderr:\n%s", config['test_id'], contents) |
68b3a741 | 366 | contents, _ = get_file(test.filenames['stdout']) |
fb551941 VF |
367 | logging.debug("Test %d: stdout:\n%s", config['test_id'], contents) |
368 | print(f"Test {config['test_id']} {result} {desc}") | |
369 | ||
370 | print(f"{passed} test(s) passed, {failed} failed, {skipped} skipped") | |
371 | ||
372 | return passed, failed, skipped |