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