t/readonly: adapt to use fiotestlib
[fio.git] / t / fiotestlib.py
CommitLineData
fb551941
VF
1#!/usr/bin/env python3
2"""
3fiotestlib.py
4
5This library contains FioTest objects that provide convenient means to run
6different sorts of fio tests.
7
8It also contains a test runner that runs an array of dictionary objects
9describing fio tests.
10"""
11
12import os
13import sys
14import json
15import locale
16import logging
17import platform
18import traceback
19import subprocess
20from pathlib import Path
5dc5c6f6 21from fiotestcommon import get_file, SUCCESS_DEFAULT
fb551941
VF
22
23
24class FioTest():
25 """Base for all fio tests."""
26
111fa98e 27 def __init__(self, exe_path, success, testnum, artifact_root):
fb551941 28 self.success = success
111fa98e 29 self.testnum = testnum
fb551941 30 self.output = {}
fb551941
VF
31 self.passed = True
32 self.failure_reason = ''
68b3a741 33 self.parameters = None
111fa98e
VF
34 self.paths = {
35 'exe': exe_path,
36 'artifacts': artifact_root,
37 'test_dir': os.path.join(artifact_root, \
38 f"{testnum:04d}"),
39 }
40 self.filenames = {
41 'cmd': os.path.join(self.paths['test_dir'], \
42 f"{os.path.basename(self.paths['exe'])}.command"),
43 'stdout': os.path.join(self.paths['test_dir'], \
44 f"{os.path.basename(self.paths['exe'])}.stdout"),
45 'stderr': os.path.join(self.paths['test_dir'], \
46 f"{os.path.basename(self.paths['exe'])}.stderr"),
47 'exitcode': os.path.join(self.paths['test_dir'], \
48 f"{os.path.basename(self.paths['exe'])}.exitcode"),
49 }
50
51 def setup(self, parameters):
fb551941
VF
52 """Setup instance variables for test."""
53
68b3a741 54 self.parameters = parameters
68b3a741
VF
55 if not os.path.exists(self.paths['test_dir']):
56 os.mkdir(self.paths['test_dir'])
57
fb551941
VF
58 def run(self):
59 """Run the test."""
60
61 raise NotImplementedError()
62
63 def check_result(self):
64 """Check test results."""
65
66 raise NotImplementedError()
67
68
69class FioExeTest(FioTest):
70 """Test consists of an executable binary or script"""
71
fb551941
VF
72 def run(self):
73 """Execute the binary or script described by this instance."""
74
68b3a741 75 command = [self.paths['exe']] + self.parameters
1cf0ba9d
VF
76 with open(self.filenames['cmd'], "w+",
77 encoding=locale.getpreferredencoding()) as command_file:
8cb1dbbe 78 command_file.write(" ".join(command))
1cf0ba9d 79
fb551941 80 try:
1cf0ba9d
VF
81 with open(self.filenames['stdout'], "w+",
82 encoding=locale.getpreferredencoding()) as stdout_file, \
83 open(self.filenames['stderr'], "w+",
84 encoding=locale.getpreferredencoding()) as stderr_file, \
85 open(self.filenames['exitcode'], "w+",
86 encoding=locale.getpreferredencoding()) as exitcode_file:
87 proc = None
88 # Avoid using subprocess.run() here because when a timeout occurs,
89 # fio will be stopped with SIGKILL. This does not give fio a
90 # chance to clean up and means that child processes may continue
91 # running and submitting IO.
92 proc = subprocess.Popen(command,
93 stdout=stdout_file,
94 stderr=stderr_file,
95 cwd=self.paths['test_dir'],
96 universal_newlines=True)
97 proc.communicate(timeout=self.success['timeout'])
98 exitcode_file.write(f'{proc.returncode}\n')
99 logging.debug("Test %d: return code: %d", self.testnum, proc.returncode)
100 self.output['proc'] = proc
fb551941
VF
101 except subprocess.TimeoutExpired:
102 proc.terminate()
103 proc.communicate()
104 assert proc.poll()
105 self.output['failure'] = 'timeout'
106 except Exception:
107 if proc:
108 if not proc.poll():
109 proc.terminate()
110 proc.communicate()
111 self.output['failure'] = 'exception'
112 self.output['exc_info'] = sys.exc_info()
fb551941
VF
113
114 def check_result(self):
115 """Check results of test run."""
116
117 if 'proc' not in self.output:
118 if self.output['failure'] == 'timeout':
119 self.failure_reason = f"{self.failure_reason} timeout,"
120 else:
121 assert self.output['failure'] == 'exception'
6544f1ae
VF
122 self.failure_reason = f'{self.failure_reason} exception: ' + \
123 f'{self.output["exc_info"][0]}, {self.output["exc_info"][1]}'
fb551941
VF
124
125 self.passed = False
126 return
127
128 if 'zero_return' in self.success:
129 if self.success['zero_return']:
130 if self.output['proc'].returncode != 0:
131 self.passed = False
132 self.failure_reason = f"{self.failure_reason} non-zero return code,"
133 else:
134 if self.output['proc'].returncode == 0:
135 self.failure_reason = f"{self.failure_reason} zero return code,"
136 self.passed = False
137
68b3a741 138 stderr_size = os.path.getsize(self.filenames['stderr'])
fb551941
VF
139 if 'stderr_empty' in self.success:
140 if self.success['stderr_empty']:
141 if stderr_size != 0:
142 self.failure_reason = f"{self.failure_reason} stderr not empty,"
143 self.passed = False
144 else:
145 if stderr_size == 0:
146 self.failure_reason = f"{self.failure_reason} stderr empty,"
147 self.passed = False
148
149
0dc6e911 150class FioJobFileTest(FioExeTest):
68b3a741 151 """Test consists of a fio job with options in a job file."""
fb551941 152
111fa98e
VF
153 def __init__(self, fio_path, fio_job, success, testnum, artifact_root,
154 fio_pre_job=None, fio_pre_success=None,
155 output_format="normal"):
0dc6e911 156 """Construct a FioJobFileTest which is a FioExeTest consisting of a
fb551941
VF
157 single fio job file with an optional setup step.
158
159 fio_path: location of fio executable
160 fio_job: location of fio job file
161 success: Definition of test success
111fa98e
VF
162 testnum: test ID
163 artifact_root: root directory for artifacts
fb551941
VF
164 fio_pre_job: fio job for preconditioning
165 fio_pre_success: Definition of test success for fio precon job
166 output_format: normal (default), json, jsonplus, or terse
167 """
168
169 self.fio_job = fio_job
170 self.fio_pre_job = fio_pre_job
171 self.fio_pre_success = fio_pre_success if fio_pre_success else success
172 self.output_format = output_format
173 self.precon_failed = False
174 self.json_data = None
68b3a741 175
111fa98e 176 super().__init__(fio_path, success, testnum, artifact_root)
68b3a741 177
111fa98e 178 def setup(self, parameters=None):
68b3a741
VF
179 """Setup instance variables for fio job test."""
180
181 self.filenames['fio_output'] = f"{os.path.basename(self.fio_job)}.output"
182 fio_args = [
fb551941
VF
183 "--max-jobs=16",
184 f"--output-format={self.output_format}",
68b3a741 185 f"--output={self.filenames['fio_output']}",
fb551941
VF
186 self.fio_job,
187 ]
fb551941 188
111fa98e 189 super().setup(fio_args)
fb551941 190
68b3a741
VF
191 # Update the filenames from the default
192 self.filenames['cmd'] = os.path.join(self.paths['test_dir'],
193 f"{os.path.basename(self.fio_job)}.command")
194 self.filenames['stdout'] = os.path.join(self.paths['test_dir'],
195 f"{os.path.basename(self.fio_job)}.stdout")
196 self.filenames['stderr'] = os.path.join(self.paths['test_dir'],
197 f"{os.path.basename(self.fio_job)}.stderr")
198 self.filenames['exitcode'] = os.path.join(self.paths['test_dir'],
199 f"{os.path.basename(self.fio_job)}.exitcode")
fb551941
VF
200
201 def run_pre_job(self):
202 """Run fio job precondition step."""
203
68b3a741 204 precon = FioJobFileTest(self.paths['exe'], self.fio_pre_job,
fb551941 205 self.fio_pre_success,
111fa98e
VF
206 self.testnum,
207 self.paths['artifacts'],
fb551941 208 output_format=self.output_format)
111fa98e 209 precon.setup()
fb551941
VF
210 precon.run()
211 precon.check_result()
212 self.precon_failed = not precon.passed
213 self.failure_reason = precon.failure_reason
214
215 def run(self):
216 """Run fio job test."""
217
218 if self.fio_pre_job:
219 self.run_pre_job()
220
221 if not self.precon_failed:
222 super().run()
223 else:
224 logging.debug("Test %d: precondition step failed", self.testnum)
225
fb551941
VF
226 def get_file_fail(self, filename):
227 """Safely read a file and fail the test upon error."""
228 file_data = None
229
230 try:
231 with open(filename, "r", encoding=locale.getpreferredencoding()) as output_file:
232 file_data = output_file.read()
233 except OSError:
234 self.failure_reason += f" unable to read file {filename}"
235 self.passed = False
236
237 return file_data
238
239 def check_result(self):
240 """Check fio job results."""
241
242 if self.precon_failed:
243 self.passed = False
244 self.failure_reason = f"{self.failure_reason} precondition step failed,"
245 return
246
247 super().check_result()
248
249 if not self.passed:
250 return
251
252 if 'json' not in self.output_format:
253 return
254
68b3a741
VF
255 file_data = self.get_file_fail(os.path.join(self.paths['test_dir'],
256 self.filenames['fio_output']))
fb551941
VF
257 if not file_data:
258 return
259
260 #
261 # Sometimes fio informational messages are included at the top of the
262 # JSON output, especially under Windows. Try to decode output as JSON
263 # data, skipping everything until the first {
264 #
265 lines = file_data.splitlines()
266 file_data = '\n'.join(lines[lines.index("{"):])
267 try:
268 self.json_data = json.loads(file_data)
269 except json.JSONDecodeError:
270 self.failure_reason = f"{self.failure_reason} unable to decode JSON data,"
271 self.passed = False
272
273
5dc5c6f6
VF
274class FioJobCmdTest(FioExeTest):
275 """This runs a fio job with options specified on the command line."""
276
277 def __init__(self, fio_path, success, testnum, artifact_root, fio_opts, basename=None):
278
279 self.basename = basename if basename else os.path.basename(fio_path)
280 self.fio_opts = fio_opts
281 self.json_data = None
282
283 super().__init__(fio_path, success, testnum, artifact_root)
284
285 filename_stub = f"{self.basename}{self.testnum:03d}"
286 self.filenames['cmd'] = os.path.join(self.paths['test_dir'], f"{filename_stub}.command")
287 self.filenames['stdout'] = os.path.join(self.paths['test_dir'], f"{filename_stub}.stdout")
288 self.filenames['stderr'] = os.path.join(self.paths['test_dir'], f"{filename_stub}.stderr")
289 self.filenames['output'] = os.path.abspath(os.path.join(self.paths['test_dir'],
290 f"{filename_stub}.output"))
291 self.filenames['exitcode'] = os.path.join(self.paths['test_dir'],
292 f"{filename_stub}.exitcode")
293
294 def run(self):
295 super().run()
296
297 if 'output-format' in self.fio_opts and 'json' in \
298 self.fio_opts['output-format']:
299 if not self.get_json():
300 print('Unable to decode JSON data')
301 self.passed = False
302
303 def get_json(self):
304 """Convert fio JSON output into a python JSON object"""
305
306 filename = self.filenames['output']
307 with open(filename, 'r', encoding=locale.getpreferredencoding()) as file:
308 file_data = file.read()
309
310 #
311 # Sometimes fio informational messages are included at the top of the
312 # JSON output, especially under Windows. Try to decode output as JSON
313 # data, lopping off up to the first four lines
314 #
315 lines = file_data.splitlines()
316 for i in range(5):
317 file_data = '\n'.join(lines[i:])
318 try:
319 self.json_data = json.loads(file_data)
320 except json.JSONDecodeError:
321 continue
322 else:
323 return True
324
325 return False
326
327 @staticmethod
328 def check_empty(job):
329 """
330 Make sure JSON data is empty.
331
332 Some data structures should be empty. This function makes sure that they are.
333
334 job JSON object that we need to check for emptiness
335 """
336
337 return job['total_ios'] == 0 and \
338 job['slat_ns']['N'] == 0 and \
339 job['clat_ns']['N'] == 0 and \
340 job['lat_ns']['N'] == 0
341
342 def check_all_ddirs(self, ddir_nonzero, job):
343 """
344 Iterate over the data directions and check whether each is
345 appropriately empty or not.
346 """
347
348 retval = True
349 ddirlist = ['read', 'write', 'trim']
350
351 for ddir in ddirlist:
352 if ddir in ddir_nonzero:
353 if self.check_empty(job[ddir]):
354 print(f"Unexpected zero {ddir} data found in output")
355 retval = False
356 else:
357 if not self.check_empty(job[ddir]):
358 print(f"Unexpected {ddir} data found in output")
359 retval = False
360
361 return retval
362
363
fb551941
VF
364def run_fio_tests(test_list, test_env, args):
365 """
366 Run tests as specified in test_list.
367 """
368
369 passed = 0
370 failed = 0
371 skipped = 0
372
373 for config in test_list:
374 if (args.skip and config['test_id'] in args.skip) or \
375 (args.run_only and config['test_id'] not in args.run_only):
376 skipped = skipped + 1
377 print(f"Test {config['test_id']} SKIPPED (User request)")
378 continue
379
0dc6e911 380 if issubclass(config['test_class'], FioJobFileTest):
fb551941
VF
381 if config['pre_job']:
382 fio_pre_job = os.path.join(test_env['fio_root'], 't', 'jobs',
383 config['pre_job'])
384 else:
385 fio_pre_job = None
386 if config['pre_success']:
387 fio_pre_success = config['pre_success']
388 else:
389 fio_pre_success = None
390 if 'output_format' in config:
391 output_format = config['output_format']
392 else:
393 output_format = 'normal'
394 test = config['test_class'](
395 test_env['fio_path'],
396 os.path.join(test_env['fio_root'], 't', 'jobs', config['job']),
397 config['success'],
111fa98e
VF
398 config['test_id'],
399 test_env['artifact_root'],
fb551941
VF
400 fio_pre_job=fio_pre_job,
401 fio_pre_success=fio_pre_success,
402 output_format=output_format)
403 desc = config['job']
68b3a741 404 parameters = []
5dc5c6f6
VF
405 elif issubclass(config['test_class'], FioJobCmdTest):
406 if not 'success' in config:
407 config['success'] = SUCCESS_DEFAULT
408 test = config['test_class'](test_env['fio_path'],
409 config['success'],
410 config['test_id'],
411 test_env['artifact_root'],
412 config['fio_opts'],
413 test_env['basename'])
414 desc = config['test_id']
415 parameters = config
fb551941
VF
416 elif issubclass(config['test_class'], FioExeTest):
417 exe_path = os.path.join(test_env['fio_root'], config['exe'])
68b3a741 418 parameters = []
fb551941
VF
419 if config['parameters']:
420 parameters = [p.format(fio_path=test_env['fio_path'], nvmecdev=args.nvmecdev)
421 for p in config['parameters']]
fb551941
VF
422 if Path(exe_path).suffix == '.py' and platform.system() == "Windows":
423 parameters.insert(0, exe_path)
424 exe_path = "python.exe"
425 if config['test_id'] in test_env['pass_through']:
426 parameters += test_env['pass_through'][config['test_id']].split()
111fa98e
VF
427 test = config['test_class'](
428 exe_path,
429 config['success'],
430 config['test_id'],
431 test_env['artifact_root'])
fb551941
VF
432 desc = config['exe']
433 else:
434 print(f"Test {config['test_id']} FAILED: unable to process test config")
435 failed = failed + 1
436 continue
437
5dc5c6f6 438 if 'requirements' in config and not args.skip_req:
fb551941
VF
439 reqs_met = True
440 for req in config['requirements']:
441 reqs_met, reason = req()
442 logging.debug("Test %d: Requirement '%s' met? %s", config['test_id'], reason,
443 reqs_met)
444 if not reqs_met:
445 break
446 if not reqs_met:
447 print(f"Test {config['test_id']} SKIPPED ({reason}) {desc}")
448 skipped = skipped + 1
449 continue
450
451 try:
111fa98e 452 test.setup(parameters)
fb551941
VF
453 test.run()
454 test.check_result()
455 except KeyboardInterrupt:
456 break
457 except Exception as e:
458 test.passed = False
459 test.failure_reason += str(e)
460 logging.debug("Test %d exception:\n%s\n", config['test_id'], traceback.format_exc())
461 if test.passed:
462 result = "PASSED"
463 passed = passed + 1
464 else:
465 result = f"FAILED: {test.failure_reason}"
466 failed = failed + 1
68b3a741 467 contents, _ = get_file(test.filenames['stderr'])
fb551941 468 logging.debug("Test %d: stderr:\n%s", config['test_id'], contents)
68b3a741 469 contents, _ = get_file(test.filenames['stdout'])
fb551941
VF
470 logging.debug("Test %d: stdout:\n%s", config['test_id'], contents)
471 print(f"Test {config['test_id']} {result} {desc}")
472
473 print(f"{passed} test(s) passed, {failed} failed, {skipped} skipped")
474
475 return passed, failed, skipped