t/run-fio-tests: better catch file errors
[fio.git] / t / run-fio-tests.py
1 #!/usr/bin/env python3
2 # SPDX-License-Identifier: GPL-2.0-only
3 #
4 # Copyright (c) 2019 Western Digital Corporation or its affiliates.
5 #
6 """
7 # run-fio-tests.py
8 #
9 # Automate running of fio tests
10 #
11 # USAGE
12 # python3 run-fio-tests.py [-r fio-root] [-f fio-path] [-a artifact-root]
13 #                           [--skip # # #...] [--run-only # # #...]
14 #
15 #
16 # EXAMPLE
17 # # git clone git://git.kernel.dk/fio.git
18 # # cd fio
19 # # make -j
20 # # python3 t/run-fio-tests.py
21 #
22 #
23 # REQUIREMENTS
24 # - Python 3.5 (subprocess.run)
25 # - Linux (libaio ioengine, zbd tests, etc)
26 # - The artifact directory must be on a file system that accepts 512-byte IO
27 #   (t0002, t0003, t0004).
28 # - The artifact directory needs to be on an SSD. Otherwise tests that carry
29 #   out file-based IO will trigger a timeout (t0006).
30 # - 4 CPUs (t0009)
31 # - SciPy (steadystate_tests.py)
32 # - libzbc (zbd tests)
33 # - root privileges (zbd test)
34 # - kernel 4.19 or later for zoned null block devices (zbd tests)
35 # - CUnit support (unittests)
36 #
37 """
38
39 #
40 # TODO  run multiple tests simultaneously
41 # TODO  Add sgunmap tests (requires SAS SSD)
42 #
43
44 import os
45 import sys
46 import json
47 import time
48 import shutil
49 import logging
50 import argparse
51 import platform
52 import subprocess
53 import multiprocessing
54 from pathlib import Path
55
56
57 class FioTest(object):
58     """Base for all fio tests."""
59
60     def __init__(self, exe_path, parameters, success):
61         self.exe_path = exe_path
62         self.parameters = parameters
63         self.success = success
64         self.output = {}
65         self.artifact_root = None
66         self.testnum = None
67         self.test_dir = None
68         self.passed = True
69         self.failure_reason = ''
70         self.command_file = None
71         self.stdout_file = None
72         self.stderr_file = None
73         self.exitcode_file = None
74
75     def setup(self, artifact_root, testnum):
76         """Setup instance variables for test."""
77
78         self.artifact_root = artifact_root
79         self.testnum = testnum
80         self.test_dir = os.path.join(artifact_root, "{:04d}".format(testnum))
81         if not os.path.exists(self.test_dir):
82             os.mkdir(self.test_dir)
83
84         self.command_file = os.path.join(
85             self.test_dir,
86             "{0}.command".format(os.path.basename(self.exe_path)))
87         self.stdout_file = os.path.join(
88             self.test_dir,
89             "{0}.stdout".format(os.path.basename(self.exe_path)))
90         self.stderr_file = os.path.join(
91             self.test_dir,
92             "{0}.stderr".format(os.path.basename(self.exe_path)))
93         self.exitcode_file = os.path.join(
94             self.test_dir,
95             "{0}.exitcode".format(os.path.basename(self.exe_path)))
96
97     def run(self):
98         """Run the test."""
99
100         raise NotImplementedError()
101
102     def check_result(self):
103         """Check test results."""
104
105         raise NotImplementedError()
106
107
108 class FioExeTest(FioTest):
109     """Test consists of an executable binary or script"""
110
111     def __init__(self, exe_path, parameters, success):
112         """Construct a FioExeTest which is a FioTest consisting of an
113         executable binary or script.
114
115         exe_path:       location of executable binary or script
116         parameters:     list of parameters for executable
117         success:        Definition of test success
118         """
119
120         FioTest.__init__(self, exe_path, parameters, success)
121
122     def run(self):
123         """Execute the binary or script described by this instance."""
124
125         if self.parameters:
126             command = [self.exe_path] + self.parameters
127         else:
128             command = [self.exe_path]
129         command_file = open(self.command_file, "w+")
130         command_file.write("%s\n" % command)
131         command_file.close()
132
133         stdout_file = open(self.stdout_file, "w+")
134         stderr_file = open(self.stderr_file, "w+")
135         exitcode_file = open(self.exitcode_file, "w+")
136         try:
137             proc = None
138             # Avoid using subprocess.run() here because when a timeout occurs,
139             # fio will be stopped with SIGKILL. This does not give fio a
140             # chance to clean up and means that child processes may continue
141             # running and submitting IO.
142             proc = subprocess.Popen(command,
143                                     stdout=stdout_file,
144                                     stderr=stderr_file,
145                                     cwd=self.test_dir,
146                                     universal_newlines=True)
147             proc.communicate(timeout=self.success['timeout'])
148             exitcode_file.write('{0}\n'.format(proc.returncode))
149             logging.debug("Test %d: return code: %d", self.testnum, proc.returncode)
150             self.output['proc'] = proc
151         except subprocess.TimeoutExpired:
152             proc.terminate()
153             proc.communicate()
154             assert proc.poll()
155             self.output['failure'] = 'timeout'
156         except Exception:
157             if proc:
158                 if not proc.poll():
159                     proc.terminate()
160                     proc.communicate()
161             self.output['failure'] = 'exception'
162             self.output['exc_info'] = sys.exc_info()
163         finally:
164             stdout_file.close()
165             stderr_file.close()
166             exitcode_file.close()
167
168     def check_result(self):
169         """Check results of test run."""
170
171         if 'proc' not in self.output:
172             if self.output['failure'] == 'timeout':
173                 self.failure_reason = "{0} timeout,".format(self.failure_reason)
174             else:
175                 assert self.output['failure'] == 'exception'
176                 self.failure_reason = '{0} exception: {1}, {2}'.format(
177                     self.failure_reason, self.output['exc_info'][0],
178                     self.output['exc_info'][1])
179
180             self.passed = False
181             return
182
183         if 'zero_return' in self.success:
184             if self.success['zero_return']:
185                 if self.output['proc'].returncode != 0:
186                     self.passed = False
187                     self.failure_reason = "{0} non-zero return code,".format(self.failure_reason)
188             else:
189                 if self.output['proc'].returncode == 0:
190                     self.failure_reason = "{0} zero return code,".format(self.failure_reason)
191                     self.passed = False
192
193         stderr_size = os.path.getsize(self.stderr_file)
194         if 'stderr_empty' in self.success:
195             if self.success['stderr_empty']:
196                 if stderr_size != 0:
197                     self.failure_reason = "{0} stderr not empty,".format(self.failure_reason)
198                     self.passed = False
199             else:
200                 if stderr_size == 0:
201                     self.failure_reason = "{0} stderr empty,".format(self.failure_reason)
202                     self.passed = False
203
204
205 class FioJobTest(FioExeTest):
206     """Test consists of a fio job"""
207
208     def __init__(self, fio_path, fio_job, success, fio_pre_job=None,
209                  fio_pre_success=None, output_format="normal"):
210         """Construct a FioJobTest which is a FioExeTest consisting of a
211         single fio job file with an optional setup step.
212
213         fio_path:           location of fio executable
214         fio_job:            location of fio job file
215         success:            Definition of test success
216         fio_pre_job:        fio job for preconditioning
217         fio_pre_success:    Definition of test success for fio precon job
218         output_format:      normal (default), json, jsonplus, or terse
219         """
220
221         self.fio_job = fio_job
222         self.fio_pre_job = fio_pre_job
223         self.fio_pre_success = fio_pre_success if fio_pre_success else success
224         self.output_format = output_format
225         self.precon_failed = False
226         self.json_data = None
227         self.fio_output = "{0}.output".format(os.path.basename(self.fio_job))
228         self.fio_args = [
229             "--output-format={0}".format(self.output_format),
230             "--output={0}".format(self.fio_output),
231             self.fio_job,
232             ]
233         FioExeTest.__init__(self, fio_path, self.fio_args, success)
234
235     def setup(self, artifact_root, testnum):
236         """Setup instance variables for fio job test."""
237
238         super(FioJobTest, self).setup(artifact_root, testnum)
239
240         self.command_file = os.path.join(
241             self.test_dir,
242             "{0}.command".format(os.path.basename(self.fio_job)))
243         self.stdout_file = os.path.join(
244             self.test_dir,
245             "{0}.stdout".format(os.path.basename(self.fio_job)))
246         self.stderr_file = os.path.join(
247             self.test_dir,
248             "{0}.stderr".format(os.path.basename(self.fio_job)))
249         self.exitcode_file = os.path.join(
250             self.test_dir,
251             "{0}.exitcode".format(os.path.basename(self.fio_job)))
252
253     def run_pre_job(self):
254         """Run fio job precondition step."""
255
256         precon = FioJobTest(self.exe_path, self.fio_pre_job,
257                             self.fio_pre_success,
258                             output_format=self.output_format)
259         precon.setup(self.artifact_root, self.testnum)
260         precon.run()
261         precon.check_result()
262         self.precon_failed = not precon.passed
263         self.failure_reason = precon.failure_reason
264
265     def run(self):
266         """Run fio job test."""
267
268         if self.fio_pre_job:
269             self.run_pre_job()
270
271         if not self.precon_failed:
272             super(FioJobTest, self).run()
273         else:
274             logging.debug("Test %d: precondition step failed", self.testnum)
275
276     @classmethod
277     def get_file(cls, filename):
278         """Safely read a file."""
279         file_data = ''
280         success = True
281
282         try:
283             with open(filename, "r") as output_file:
284                 file_data = output_file.read()
285         except OSError:
286             success = False
287
288         return file_data, success
289
290     def check_result(self):
291         """Check fio job results."""
292
293         if self.precon_failed:
294             self.passed = False
295             self.failure_reason = "{0} precondition step failed,".format(self.failure_reason)
296             return
297
298         super(FioJobTest, self).check_result()
299
300         if not self.passed:
301             return
302
303         if 'json' not in self.output_format:
304             return
305
306         file_data, success = self.get_file(os.path.join(self.test_dir, self.fio_output))
307         if not success:
308             self.failure_reason = "{0} unable to open output file,".format(self.failure_reason)
309             self.passed = False
310             return
311
312         #
313         # Sometimes fio informational messages are included at the top of the
314         # JSON output, especially under Windows. Try to decode output as JSON
315         # data, lopping off up to the first four lines
316         #
317         lines = file_data.splitlines()
318         for i in range(5):
319             file_data = '\n'.join(lines[i:])
320             try:
321                 self.json_data = json.loads(file_data)
322             except json.JSONDecodeError:
323                 continue
324             else:
325                 logging.debug("Test %d: skipped %d lines decoding JSON data", self.testnum, i)
326                 return
327
328         self.failure_reason = "{0} unable to decode JSON data,".format(self.failure_reason)
329         self.passed = False
330
331
332 class FioJobTest_t0005(FioJobTest):
333     """Test consists of fio test job t0005
334     Confirm that read['io_kbytes'] == write['io_kbytes'] == 102400"""
335
336     def check_result(self):
337         super(FioJobTest_t0005, self).check_result()
338
339         if not self.passed:
340             return
341
342         if self.json_data['jobs'][0]['read']['io_kbytes'] != 102400:
343             self.failure_reason = "{0} bytes read mismatch,".format(self.failure_reason)
344             self.passed = False
345         if self.json_data['jobs'][0]['write']['io_kbytes'] != 102400:
346             self.failure_reason = "{0} bytes written mismatch,".format(self.failure_reason)
347             self.passed = False
348
349
350 class FioJobTest_t0006(FioJobTest):
351     """Test consists of fio test job t0006
352     Confirm that read['io_kbytes'] ~ 2*write['io_kbytes']"""
353
354     def check_result(self):
355         super(FioJobTest_t0006, self).check_result()
356
357         if not self.passed:
358             return
359
360         ratio = self.json_data['jobs'][0]['read']['io_kbytes'] \
361             / self.json_data['jobs'][0]['write']['io_kbytes']
362         logging.debug("Test %d: ratio: %f", self.testnum, ratio)
363         if ratio < 1.99 or ratio > 2.01:
364             self.failure_reason = "{0} read/write ratio mismatch,".format(self.failure_reason)
365             self.passed = False
366
367
368 class FioJobTest_t0007(FioJobTest):
369     """Test consists of fio test job t0007
370     Confirm that read['io_kbytes'] = 87040"""
371
372     def check_result(self):
373         super(FioJobTest_t0007, self).check_result()
374
375         if not self.passed:
376             return
377
378         if self.json_data['jobs'][0]['read']['io_kbytes'] != 87040:
379             self.failure_reason = "{0} bytes read mismatch,".format(self.failure_reason)
380             self.passed = False
381
382
383 class FioJobTest_t0008(FioJobTest):
384     """Test consists of fio test job t0008
385     Confirm that read['io_kbytes'] = 32768 and that
386                 write['io_kbytes'] ~ 16568
387
388     I did runs with fio-ae2fafc8 and saw write['io_kbytes'] values of
389     16585, 16588. With two runs of fio-3.16 I obtained 16568"""
390
391     def check_result(self):
392         super(FioJobTest_t0008, self).check_result()
393
394         if not self.passed:
395             return
396
397         ratio = self.json_data['jobs'][0]['write']['io_kbytes'] / 16568
398         logging.debug("Test %d: ratio: %f", self.testnum, ratio)
399
400         if ratio < 0.99 or ratio > 1.01:
401             self.failure_reason = "{0} bytes written mismatch,".format(self.failure_reason)
402             self.passed = False
403         if self.json_data['jobs'][0]['read']['io_kbytes'] != 32768:
404             self.failure_reason = "{0} bytes read mismatch,".format(self.failure_reason)
405             self.passed = False
406
407
408 class FioJobTest_t0009(FioJobTest):
409     """Test consists of fio test job t0009
410     Confirm that runtime >= 60s"""
411
412     def check_result(self):
413         super(FioJobTest_t0009, self).check_result()
414
415         if not self.passed:
416             return
417
418         logging.debug('Test %d: elapsed: %d', self.testnum, self.json_data['jobs'][0]['elapsed'])
419
420         if self.json_data['jobs'][0]['elapsed'] < 60:
421             self.failure_reason = "{0} elapsed time mismatch,".format(self.failure_reason)
422             self.passed = False
423
424
425 class FioJobTest_t0011(FioJobTest):
426     """Test consists of fio test job t0009
427     Confirm that job0 iops == 1000
428     and that job1_iops / job0_iops ~ 8
429     With two runs of fio-3.16 I observed a ratio of 8.3"""
430
431     def check_result(self):
432         super(FioJobTest_t0011, self).check_result()
433
434         if not self.passed:
435             return
436
437         iops1 = self.json_data['jobs'][0]['read']['iops']
438         iops2 = self.json_data['jobs'][1]['read']['iops']
439         ratio = iops2 / iops1
440         logging.debug("Test %d: iops1: %f", self.testnum, iops1)
441         logging.debug("Test %d: ratio: %f", self.testnum, ratio)
442
443         if iops1 < 998 or iops1 > 1002:
444             self.failure_reason = "{0} iops value mismatch,".format(self.failure_reason)
445             self.passed = False
446
447         if ratio < 7 or ratio > 9:
448             self.failure_reason = "{0} iops ratio mismatch,".format(self.failure_reason)
449             self.passed = False
450
451
452 class Requirements(object):
453     """Requirements consists of multiple run environment characteristics.
454     These are to determine if a particular test can be run"""
455
456     _linux = False
457     _libaio = False
458     _zbd = False
459     _root = False
460     _zoned_nullb = False
461     _not_macos = False
462     _not_windows = False
463     _unittests = False
464     _cpucount4 = False
465
466     def __init__(self, fio_root):
467         Requirements._not_macos = platform.system() != "Darwin"
468         Requirements._not_windows = platform.system() != "Windows"
469         Requirements._linux = platform.system() == "Linux"
470
471         if Requirements._linux:
472             config_file = os.path.join(fio_root, "config-host.h")
473             contents, success = FioJobTest.get_file(config_file)
474             if not success:
475                 print("Unable to open {0} to check requirements".format(config_file))
476                 Requirements._zbd = True
477             else:
478                 Requirements._zbd = "CONFIG_HAS_BLKZONED" in contents
479                 Requirements._libaio = "CONFIG_LIBAIO" in contents
480
481             Requirements._root = (os.geteuid() == 0)
482             if Requirements._zbd and Requirements._root:
483                 subprocess.run(["modprobe", "null_blk"],
484                                stdout=subprocess.PIPE,
485                                stderr=subprocess.PIPE)
486                 if os.path.exists("/sys/module/null_blk/parameters/zoned"):
487                     Requirements._zoned_nullb = True
488
489         if platform.system() == "Windows":
490             utest_exe = "unittest.exe"
491         else:
492             utest_exe = "unittest"
493         unittest_path = os.path.join(fio_root, "unittests", utest_exe)
494         Requirements._unittests = os.path.exists(unittest_path)
495
496         Requirements._cpucount4 = multiprocessing.cpu_count() >= 4
497
498         req_list = [Requirements.linux,
499                     Requirements.libaio,
500                     Requirements.zbd,
501                     Requirements.root,
502                     Requirements.zoned_nullb,
503                     Requirements.not_macos,
504                     Requirements.not_windows,
505                     Requirements.unittests,
506                     Requirements.cpucount4]
507         for req in req_list:
508             value, desc = req()
509             logging.debug("Requirements: Requirement '%s' met? %s", desc, value)
510
511     @classmethod
512     def linux(cls):
513         """Are we running on Linux?"""
514         return Requirements._linux, "Linux required"
515
516     @classmethod
517     def libaio(cls):
518         """Is libaio available?"""
519         return Requirements._libaio, "libaio required"
520
521     @classmethod
522     def zbd(cls):
523         """Is ZBD support available?"""
524         return Requirements._zbd, "Zoned block device support required"
525
526     @classmethod
527     def root(cls):
528         """Are we running as root?"""
529         return Requirements._root, "root required"
530
531     @classmethod
532     def zoned_nullb(cls):
533         """Are zoned null block devices available?"""
534         return Requirements._zoned_nullb, "Zoned null block device support required"
535
536     @classmethod
537     def not_macos(cls):
538         """Are we running on a platform other than macOS?"""
539         return Requirements._not_macos, "platform other than macOS required"
540
541     @classmethod
542     def not_windows(cls):
543         """Are we running on a platform other than Windws?"""
544         return Requirements._not_windows, "platform other than Windows required"
545
546     @classmethod
547     def unittests(cls):
548         """Were unittests built?"""
549         return Requirements._unittests, "Unittests support required"
550
551     @classmethod
552     def cpucount4(cls):
553         """Do we have at least 4 CPUs?"""
554         return Requirements._cpucount4, "4+ CPUs required"
555
556
557 SUCCESS_DEFAULT = {
558     'zero_return': True,
559     'stderr_empty': True,
560     'timeout': 600,
561     }
562 SUCCESS_NONZERO = {
563     'zero_return': False,
564     'stderr_empty': False,
565     'timeout': 600,
566     }
567 SUCCESS_STDERR = {
568     'zero_return': True,
569     'stderr_empty': False,
570     'timeout': 600,
571     }
572 TEST_LIST = [
573     {
574         'test_id':          1,
575         'test_class':       FioJobTest,
576         'job':              't0001-52c58027.fio',
577         'success':          SUCCESS_DEFAULT,
578         'pre_job':          None,
579         'pre_success':      None,
580         'requirements':     [],
581     },
582     {
583         'test_id':          2,
584         'test_class':       FioJobTest,
585         'job':              't0002-13af05ae-post.fio',
586         'success':          SUCCESS_DEFAULT,
587         'pre_job':          't0002-13af05ae-pre.fio',
588         'pre_success':      None,
589         'requirements':     [Requirements.linux, Requirements.libaio],
590     },
591     {
592         'test_id':          3,
593         'test_class':       FioJobTest,
594         'job':              't0003-0ae2c6e1-post.fio',
595         'success':          SUCCESS_NONZERO,
596         'pre_job':          't0003-0ae2c6e1-pre.fio',
597         'pre_success':      SUCCESS_DEFAULT,
598         'requirements':     [Requirements.linux, Requirements.libaio],
599     },
600     {
601         'test_id':          4,
602         'test_class':       FioJobTest,
603         'job':              't0004-8a99fdf6.fio',
604         'success':          SUCCESS_DEFAULT,
605         'pre_job':          None,
606         'pre_success':      None,
607         'requirements':     [Requirements.linux, Requirements.libaio],
608     },
609     {
610         'test_id':          5,
611         'test_class':       FioJobTest_t0005,
612         'job':              't0005-f7078f7b.fio',
613         'success':          SUCCESS_DEFAULT,
614         'pre_job':          None,
615         'pre_success':      None,
616         'output_format':    'json',
617         'requirements':     [Requirements.not_windows],
618     },
619     {
620         'test_id':          6,
621         'test_class':       FioJobTest_t0006,
622         'job':              't0006-82af2a7c.fio',
623         'success':          SUCCESS_DEFAULT,
624         'pre_job':          None,
625         'pre_success':      None,
626         'output_format':    'json',
627         'requirements':     [Requirements.linux, Requirements.libaio],
628     },
629     {
630         'test_id':          7,
631         'test_class':       FioJobTest_t0007,
632         'job':              't0007-37cf9e3c.fio',
633         'success':          SUCCESS_DEFAULT,
634         'pre_job':          None,
635         'pre_success':      None,
636         'output_format':    'json',
637         'requirements':     [],
638     },
639     {
640         'test_id':          8,
641         'test_class':       FioJobTest_t0008,
642         'job':              't0008-ae2fafc8.fio',
643         'success':          SUCCESS_DEFAULT,
644         'pre_job':          None,
645         'pre_success':      None,
646         'output_format':    'json',
647         'requirements':     [],
648     },
649     {
650         'test_id':          9,
651         'test_class':       FioJobTest_t0009,
652         'job':              't0009-f8b0bd10.fio',
653         'success':          SUCCESS_DEFAULT,
654         'pre_job':          None,
655         'pre_success':      None,
656         'output_format':    'json',
657         'requirements':     [Requirements.not_macos,
658                              Requirements.cpucount4],
659         # mac os does not support CPU affinity
660     },
661     {
662         'test_id':          10,
663         'test_class':       FioJobTest,
664         'job':              't0010-b7aae4ba.fio',
665         'success':          SUCCESS_DEFAULT,
666         'pre_job':          None,
667         'pre_success':      None,
668         'requirements':     [],
669     },
670     {
671         'test_id':          11,
672         'test_class':       FioJobTest_t0011,
673         'job':              't0011-5d2788d5.fio',
674         'success':          SUCCESS_DEFAULT,
675         'pre_job':          None,
676         'pre_success':      None,
677         'output_format':    'json',
678         'requirements':     [],
679     },
680     {
681         'test_id':          1000,
682         'test_class':       FioExeTest,
683         'exe':              't/axmap',
684         'parameters':       None,
685         'success':          SUCCESS_DEFAULT,
686         'requirements':     [],
687     },
688     {
689         'test_id':          1001,
690         'test_class':       FioExeTest,
691         'exe':              't/ieee754',
692         'parameters':       None,
693         'success':          SUCCESS_DEFAULT,
694         'requirements':     [],
695     },
696     {
697         'test_id':          1002,
698         'test_class':       FioExeTest,
699         'exe':              't/lfsr-test',
700         'parameters':       ['0xFFFFFF', '0', '0', 'verify'],
701         'success':          SUCCESS_STDERR,
702         'requirements':     [],
703     },
704     {
705         'test_id':          1003,
706         'test_class':       FioExeTest,
707         'exe':              't/readonly.py',
708         'parameters':       ['-f', '{fio_path}'],
709         'success':          SUCCESS_DEFAULT,
710         'requirements':     [],
711     },
712     {
713         'test_id':          1004,
714         'test_class':       FioExeTest,
715         'exe':              't/steadystate_tests.py',
716         'parameters':       ['{fio_path}'],
717         'success':          SUCCESS_DEFAULT,
718         'requirements':     [],
719     },
720     {
721         'test_id':          1005,
722         'test_class':       FioExeTest,
723         'exe':              't/stest',
724         'parameters':       None,
725         'success':          SUCCESS_STDERR,
726         'requirements':     [],
727     },
728     {
729         'test_id':          1006,
730         'test_class':       FioExeTest,
731         'exe':              't/strided.py',
732         'parameters':       ['{fio_path}'],
733         'success':          SUCCESS_DEFAULT,
734         'requirements':     [],
735     },
736     {
737         'test_id':          1007,
738         'test_class':       FioExeTest,
739         'exe':              't/zbd/run-tests-against-regular-nullb',
740         'parameters':       None,
741         'success':          SUCCESS_DEFAULT,
742         'requirements':     [Requirements.linux, Requirements.zbd,
743                              Requirements.root],
744     },
745     {
746         'test_id':          1008,
747         'test_class':       FioExeTest,
748         'exe':              't/zbd/run-tests-against-zoned-nullb',
749         'parameters':       None,
750         'success':          SUCCESS_DEFAULT,
751         'requirements':     [Requirements.linux, Requirements.zbd,
752                              Requirements.root, Requirements.zoned_nullb],
753     },
754     {
755         'test_id':          1009,
756         'test_class':       FioExeTest,
757         'exe':              'unittests/unittest',
758         'parameters':       None,
759         'success':          SUCCESS_DEFAULT,
760         'requirements':     [Requirements.unittests],
761     },
762     {
763         'test_id':          1010,
764         'test_class':       FioExeTest,
765         'exe':              't/latency_percentiles.py',
766         'parameters':       ['-f', '{fio_path}'],
767         'success':          SUCCESS_DEFAULT,
768         'requirements':     [],
769     },
770     {
771         'test_id':          1011,
772         'test_class':       FioExeTest,
773         'exe':              't/jsonplus2csv_test.py',
774         'parameters':       ['-f', '{fio_path}'],
775         'success':          SUCCESS_DEFAULT,
776         'requirements':     [],
777     },
778 ]
779
780
781 def parse_args():
782     """Parse command-line arguments."""
783
784     parser = argparse.ArgumentParser()
785     parser.add_argument('-r', '--fio-root',
786                         help='fio root path')
787     parser.add_argument('-f', '--fio',
788                         help='path to fio executable (e.g., ./fio)')
789     parser.add_argument('-a', '--artifact-root',
790                         help='artifact root directory')
791     parser.add_argument('-s', '--skip', nargs='+', type=int,
792                         help='list of test(s) to skip')
793     parser.add_argument('-o', '--run-only', nargs='+', type=int,
794                         help='list of test(s) to run, skipping all others')
795     parser.add_argument('-d', '--debug', action='store_true',
796                         help='provide debug output')
797     parser.add_argument('-k', '--skip-req', action='store_true',
798                         help='skip requirements checking')
799     args = parser.parse_args()
800
801     return args
802
803
804 def main():
805     """Entry point."""
806
807     args = parse_args()
808     if args.debug:
809         logging.basicConfig(level=logging.DEBUG)
810     else:
811         logging.basicConfig(level=logging.INFO)
812
813     if args.fio_root:
814         fio_root = args.fio_root
815     else:
816         fio_root = str(Path(__file__).absolute().parent.parent)
817     print("fio root is %s" % fio_root)
818
819     if args.fio:
820         fio_path = args.fio
821     else:
822         if platform.system() == "Windows":
823             fio_exe = "fio.exe"
824         else:
825             fio_exe = "fio"
826         fio_path = os.path.join(fio_root, fio_exe)
827     print("fio path is %s" % fio_path)
828     if not shutil.which(fio_path):
829         print("Warning: fio executable not found")
830
831     artifact_root = args.artifact_root if args.artifact_root else \
832         "fio-test-{0}".format(time.strftime("%Y%m%d-%H%M%S"))
833     os.mkdir(artifact_root)
834     print("Artifact directory is %s" % artifact_root)
835
836     if not args.skip_req:
837         req = Requirements(fio_root)
838
839     passed = 0
840     failed = 0
841     skipped = 0
842
843     for config in TEST_LIST:
844         if (args.skip and config['test_id'] in args.skip) or \
845            (args.run_only and config['test_id'] not in args.run_only):
846             skipped = skipped + 1
847             print("Test {0} SKIPPED (User request)".format(config['test_id']))
848             continue
849
850         if issubclass(config['test_class'], FioJobTest):
851             if config['pre_job']:
852                 fio_pre_job = os.path.join(fio_root, 't', 'jobs',
853                                            config['pre_job'])
854             else:
855                 fio_pre_job = None
856             if config['pre_success']:
857                 fio_pre_success = config['pre_success']
858             else:
859                 fio_pre_success = None
860             if 'output_format' in config:
861                 output_format = config['output_format']
862             else:
863                 output_format = 'normal'
864             test = config['test_class'](
865                 fio_path,
866                 os.path.join(fio_root, 't', 'jobs', config['job']),
867                 config['success'],
868                 fio_pre_job=fio_pre_job,
869                 fio_pre_success=fio_pre_success,
870                 output_format=output_format)
871         elif issubclass(config['test_class'], FioExeTest):
872             exe_path = os.path.join(fio_root, config['exe'])
873             if config['parameters']:
874                 parameters = [p.format(fio_path=fio_path) for p in config['parameters']]
875             else:
876                 parameters = None
877             if Path(exe_path).suffix == '.py' and platform.system() == "Windows":
878                 if parameters:
879                     parameters.insert(0, exe_path)
880                 else:
881                     parameters = [exe_path]
882                 exe_path = "python.exe"
883             test = config['test_class'](exe_path, parameters,
884                                         config['success'])
885         else:
886             print("Test {0} FAILED: unable to process test config".format(config['test_id']))
887             failed = failed + 1
888             continue
889
890         if not args.skip_req:
891             reqs_met = True
892             for req in config['requirements']:
893                 reqs_met, reason = req()
894                 logging.debug("Test %d: Requirement '%s' met? %s", config['test_id'], reason,
895                               reqs_met)
896                 if not reqs_met:
897                     break
898             if not reqs_met:
899                 print("Test {0} SKIPPED ({1})".format(config['test_id'], reason))
900                 skipped = skipped + 1
901                 continue
902
903         test.setup(artifact_root, config['test_id'])
904         test.run()
905         test.check_result()
906         if test.passed:
907             result = "PASSED"
908             passed = passed + 1
909         else:
910             result = "FAILED: {0}".format(test.failure_reason)
911             failed = failed + 1
912             contents, _ = FioJobTest.get_file(test.stderr_file)
913             logging.debug("Test %d: stderr:\n%s", config['test_id'], contents)
914             contents, _ = FioJobTest.get_file(test.stdout_file)
915             logging.debug("Test %d: stdout:\n%s", config['test_id'], contents)
916         print("Test {0} {1}".format(config['test_id'], result))
917
918     print("{0} test(s) passed, {1} failed, {2} skipped".format(passed, failed, skipped))
919
920     sys.exit(failed)
921
922
923 if __name__ == '__main__':
924     main()