Merge branch 'master' of https://github.com/donny372/fio into master
[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_iops_rate(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_iops_rate, 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 < 950 or iops1 > 1050:
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                 try:
482                     subprocess.run(["modprobe", "null_blk"],
483                                    stdout=subprocess.PIPE,
484                                    stderr=subprocess.PIPE)
485                     if os.path.exists("/sys/module/null_blk/parameters/zoned"):
486                         Requirements._zoned_nullb = True
487                 except Exception:
488                     pass
489
490         if platform.system() == "Windows":
491             utest_exe = "unittest.exe"
492         else:
493             utest_exe = "unittest"
494         unittest_path = os.path.join(fio_root, "unittests", utest_exe)
495         Requirements._unittests = os.path.exists(unittest_path)
496
497         Requirements._cpucount4 = multiprocessing.cpu_count() >= 4
498
499         req_list = [Requirements.linux,
500                     Requirements.libaio,
501                     Requirements.zbd,
502                     Requirements.root,
503                     Requirements.zoned_nullb,
504                     Requirements.not_macos,
505                     Requirements.not_windows,
506                     Requirements.unittests,
507                     Requirements.cpucount4]
508         for req in req_list:
509             value, desc = req()
510             logging.debug("Requirements: Requirement '%s' met? %s", desc, value)
511
512     @classmethod
513     def linux(cls):
514         """Are we running on Linux?"""
515         return Requirements._linux, "Linux required"
516
517     @classmethod
518     def libaio(cls):
519         """Is libaio available?"""
520         return Requirements._libaio, "libaio required"
521
522     @classmethod
523     def zbd(cls):
524         """Is ZBD support available?"""
525         return Requirements._zbd, "Zoned block device support required"
526
527     @classmethod
528     def root(cls):
529         """Are we running as root?"""
530         return Requirements._root, "root required"
531
532     @classmethod
533     def zoned_nullb(cls):
534         """Are zoned null block devices available?"""
535         return Requirements._zoned_nullb, "Zoned null block device support required"
536
537     @classmethod
538     def not_macos(cls):
539         """Are we running on a platform other than macOS?"""
540         return Requirements._not_macos, "platform other than macOS required"
541
542     @classmethod
543     def not_windows(cls):
544         """Are we running on a platform other than Windws?"""
545         return Requirements._not_windows, "platform other than Windows required"
546
547     @classmethod
548     def unittests(cls):
549         """Were unittests built?"""
550         return Requirements._unittests, "Unittests support required"
551
552     @classmethod
553     def cpucount4(cls):
554         """Do we have at least 4 CPUs?"""
555         return Requirements._cpucount4, "4+ CPUs required"
556
557
558 SUCCESS_DEFAULT = {
559     'zero_return': True,
560     'stderr_empty': True,
561     'timeout': 600,
562     }
563 SUCCESS_NONZERO = {
564     'zero_return': False,
565     'stderr_empty': False,
566     'timeout': 600,
567     }
568 SUCCESS_STDERR = {
569     'zero_return': True,
570     'stderr_empty': False,
571     'timeout': 600,
572     }
573 TEST_LIST = [
574     {
575         'test_id':          1,
576         'test_class':       FioJobTest,
577         'job':              't0001-52c58027.fio',
578         'success':          SUCCESS_DEFAULT,
579         'pre_job':          None,
580         'pre_success':      None,
581         'requirements':     [],
582     },
583     {
584         'test_id':          2,
585         'test_class':       FioJobTest,
586         'job':              't0002-13af05ae-post.fio',
587         'success':          SUCCESS_DEFAULT,
588         'pre_job':          't0002-13af05ae-pre.fio',
589         'pre_success':      None,
590         'requirements':     [Requirements.linux, Requirements.libaio],
591     },
592     {
593         'test_id':          3,
594         'test_class':       FioJobTest,
595         'job':              't0003-0ae2c6e1-post.fio',
596         'success':          SUCCESS_NONZERO,
597         'pre_job':          't0003-0ae2c6e1-pre.fio',
598         'pre_success':      SUCCESS_DEFAULT,
599         'requirements':     [Requirements.linux, Requirements.libaio],
600     },
601     {
602         'test_id':          4,
603         'test_class':       FioJobTest,
604         'job':              't0004-8a99fdf6.fio',
605         'success':          SUCCESS_DEFAULT,
606         'pre_job':          None,
607         'pre_success':      None,
608         'requirements':     [Requirements.linux, Requirements.libaio],
609     },
610     {
611         'test_id':          5,
612         'test_class':       FioJobTest_t0005,
613         'job':              't0005-f7078f7b.fio',
614         'success':          SUCCESS_DEFAULT,
615         'pre_job':          None,
616         'pre_success':      None,
617         'output_format':    'json',
618         'requirements':     [Requirements.not_windows],
619     },
620     {
621         'test_id':          6,
622         'test_class':       FioJobTest_t0006,
623         'job':              't0006-82af2a7c.fio',
624         'success':          SUCCESS_DEFAULT,
625         'pre_job':          None,
626         'pre_success':      None,
627         'output_format':    'json',
628         'requirements':     [Requirements.linux, Requirements.libaio],
629     },
630     {
631         'test_id':          7,
632         'test_class':       FioJobTest_t0007,
633         'job':              't0007-37cf9e3c.fio',
634         'success':          SUCCESS_DEFAULT,
635         'pre_job':          None,
636         'pre_success':      None,
637         'output_format':    'json',
638         'requirements':     [],
639     },
640     {
641         'test_id':          8,
642         'test_class':       FioJobTest_t0008,
643         'job':              't0008-ae2fafc8.fio',
644         'success':          SUCCESS_DEFAULT,
645         'pre_job':          None,
646         'pre_success':      None,
647         'output_format':    'json',
648         'requirements':     [],
649     },
650     {
651         'test_id':          9,
652         'test_class':       FioJobTest_t0009,
653         'job':              't0009-f8b0bd10.fio',
654         'success':          SUCCESS_DEFAULT,
655         'pre_job':          None,
656         'pre_success':      None,
657         'output_format':    'json',
658         'requirements':     [Requirements.not_macos,
659                              Requirements.cpucount4],
660         # mac os does not support CPU affinity
661     },
662     {
663         'test_id':          10,
664         'test_class':       FioJobTest,
665         'job':              't0010-b7aae4ba.fio',
666         'success':          SUCCESS_DEFAULT,
667         'pre_job':          None,
668         'pre_success':      None,
669         'requirements':     [],
670     },
671     {
672         'test_id':          11,
673         'test_class':       FioJobTest_iops_rate,
674         'job':              't0011-5d2788d5.fio',
675         'success':          SUCCESS_DEFAULT,
676         'pre_job':          None,
677         'pre_success':      None,
678         'output_format':    'json',
679         'requirements':     [],
680     },
681     {
682         'test_id':          12,
683         'test_class':       FioJobTest_iops_rate,
684         'job':              't0012.fio',
685         'success':          SUCCESS_DEFAULT,
686         'pre_job':          None,
687         'pre_success':      None,
688         'output_format':    'json',
689         'requirements':     [Requirements.not_macos],
690         # mac os does not support CPU affinity
691         # which is required for gtod offloading
692     },
693     {
694         'test_id':          13,
695         'test_class':       FioJobTest,
696         'job':              't0013.fio',
697         'success':          SUCCESS_DEFAULT,
698         'pre_job':          None,
699         'pre_success':      None,
700         'output_format':    'json',
701         'requirements':     [],
702     },
703     {
704         'test_id':          1000,
705         'test_class':       FioExeTest,
706         'exe':              't/axmap',
707         'parameters':       None,
708         'success':          SUCCESS_DEFAULT,
709         'requirements':     [],
710     },
711     {
712         'test_id':          1001,
713         'test_class':       FioExeTest,
714         'exe':              't/ieee754',
715         'parameters':       None,
716         'success':          SUCCESS_DEFAULT,
717         'requirements':     [],
718     },
719     {
720         'test_id':          1002,
721         'test_class':       FioExeTest,
722         'exe':              't/lfsr-test',
723         'parameters':       ['0xFFFFFF', '0', '0', 'verify'],
724         'success':          SUCCESS_STDERR,
725         'requirements':     [],
726     },
727     {
728         'test_id':          1003,
729         'test_class':       FioExeTest,
730         'exe':              't/readonly.py',
731         'parameters':       ['-f', '{fio_path}'],
732         'success':          SUCCESS_DEFAULT,
733         'requirements':     [],
734     },
735     {
736         'test_id':          1004,
737         'test_class':       FioExeTest,
738         'exe':              't/steadystate_tests.py',
739         'parameters':       ['{fio_path}'],
740         'success':          SUCCESS_DEFAULT,
741         'requirements':     [],
742     },
743     {
744         'test_id':          1005,
745         'test_class':       FioExeTest,
746         'exe':              't/stest',
747         'parameters':       None,
748         'success':          SUCCESS_STDERR,
749         'requirements':     [],
750     },
751     {
752         'test_id':          1006,
753         'test_class':       FioExeTest,
754         'exe':              't/strided.py',
755         'parameters':       ['{fio_path}'],
756         'success':          SUCCESS_DEFAULT,
757         'requirements':     [],
758     },
759     {
760         'test_id':          1007,
761         'test_class':       FioExeTest,
762         'exe':              't/zbd/run-tests-against-regular-nullb',
763         'parameters':       None,
764         'success':          SUCCESS_DEFAULT,
765         'requirements':     [Requirements.linux, Requirements.zbd,
766                              Requirements.root],
767     },
768     {
769         'test_id':          1008,
770         'test_class':       FioExeTest,
771         'exe':              't/zbd/run-tests-against-zoned-nullb',
772         'parameters':       None,
773         'success':          SUCCESS_DEFAULT,
774         'requirements':     [Requirements.linux, Requirements.zbd,
775                              Requirements.root, Requirements.zoned_nullb],
776     },
777     {
778         'test_id':          1009,
779         'test_class':       FioExeTest,
780         'exe':              'unittests/unittest',
781         'parameters':       None,
782         'success':          SUCCESS_DEFAULT,
783         'requirements':     [Requirements.unittests],
784     },
785     {
786         'test_id':          1010,
787         'test_class':       FioExeTest,
788         'exe':              't/latency_percentiles.py',
789         'parameters':       ['-f', '{fio_path}'],
790         'success':          SUCCESS_DEFAULT,
791         'requirements':     [],
792     },
793     {
794         'test_id':          1011,
795         'test_class':       FioExeTest,
796         'exe':              't/jsonplus2csv_test.py',
797         'parameters':       ['-f', '{fio_path}'],
798         'success':          SUCCESS_DEFAULT,
799         'requirements':     [],
800     },
801 ]
802
803
804 def parse_args():
805     """Parse command-line arguments."""
806
807     parser = argparse.ArgumentParser()
808     parser.add_argument('-r', '--fio-root',
809                         help='fio root path')
810     parser.add_argument('-f', '--fio',
811                         help='path to fio executable (e.g., ./fio)')
812     parser.add_argument('-a', '--artifact-root',
813                         help='artifact root directory')
814     parser.add_argument('-s', '--skip', nargs='+', type=int,
815                         help='list of test(s) to skip')
816     parser.add_argument('-o', '--run-only', nargs='+', type=int,
817                         help='list of test(s) to run, skipping all others')
818     parser.add_argument('-d', '--debug', action='store_true',
819                         help='provide debug output')
820     parser.add_argument('-k', '--skip-req', action='store_true',
821                         help='skip requirements checking')
822     parser.add_argument('-p', '--pass-through', action='append',
823                         help='pass-through an argument to an executable test')
824     args = parser.parse_args()
825
826     return args
827
828
829 def main():
830     """Entry point."""
831
832     args = parse_args()
833     if args.debug:
834         logging.basicConfig(level=logging.DEBUG)
835     else:
836         logging.basicConfig(level=logging.INFO)
837
838     pass_through = {}
839     if args.pass_through:
840         for arg in args.pass_through:
841             if not ':' in arg:
842                 print("Invalid --pass-through argument '%s'" % arg)
843                 print("Syntax for --pass-through is TESTNUMBER:ARGUMENT")
844                 return
845             split = arg.split(":", 1)
846             pass_through[int(split[0])] = split[1]
847         logging.debug("Pass-through arguments: %s", pass_through)
848
849     if args.fio_root:
850         fio_root = args.fio_root
851     else:
852         fio_root = str(Path(__file__).absolute().parent.parent)
853     print("fio root is %s" % fio_root)
854
855     if args.fio:
856         fio_path = args.fio
857     else:
858         if platform.system() == "Windows":
859             fio_exe = "fio.exe"
860         else:
861             fio_exe = "fio"
862         fio_path = os.path.join(fio_root, fio_exe)
863     print("fio path is %s" % fio_path)
864     if not shutil.which(fio_path):
865         print("Warning: fio executable not found")
866
867     artifact_root = args.artifact_root if args.artifact_root else \
868         "fio-test-{0}".format(time.strftime("%Y%m%d-%H%M%S"))
869     os.mkdir(artifact_root)
870     print("Artifact directory is %s" % artifact_root)
871
872     if not args.skip_req:
873         req = Requirements(fio_root)
874
875     passed = 0
876     failed = 0
877     skipped = 0
878
879     for config in TEST_LIST:
880         if (args.skip and config['test_id'] in args.skip) or \
881            (args.run_only and config['test_id'] not in args.run_only):
882             skipped = skipped + 1
883             print("Test {0} SKIPPED (User request)".format(config['test_id']))
884             continue
885
886         if issubclass(config['test_class'], FioJobTest):
887             if config['pre_job']:
888                 fio_pre_job = os.path.join(fio_root, 't', 'jobs',
889                                            config['pre_job'])
890             else:
891                 fio_pre_job = None
892             if config['pre_success']:
893                 fio_pre_success = config['pre_success']
894             else:
895                 fio_pre_success = None
896             if 'output_format' in config:
897                 output_format = config['output_format']
898             else:
899                 output_format = 'normal'
900             test = config['test_class'](
901                 fio_path,
902                 os.path.join(fio_root, 't', 'jobs', config['job']),
903                 config['success'],
904                 fio_pre_job=fio_pre_job,
905                 fio_pre_success=fio_pre_success,
906                 output_format=output_format)
907             desc = config['job']
908         elif issubclass(config['test_class'], FioExeTest):
909             exe_path = os.path.join(fio_root, config['exe'])
910             if config['parameters']:
911                 parameters = [p.format(fio_path=fio_path) for p in config['parameters']]
912             else:
913                 parameters = []
914             if Path(exe_path).suffix == '.py' and platform.system() == "Windows":
915                 parameters.insert(0, exe_path)
916                 exe_path = "python.exe"
917             if config['test_id'] in pass_through:
918                 parameters += pass_through[config['test_id']].split()
919             test = config['test_class'](exe_path, parameters,
920                                         config['success'])
921             desc = config['exe']
922         else:
923             print("Test {0} FAILED: unable to process test config".format(config['test_id']))
924             failed = failed + 1
925             continue
926
927         if not args.skip_req:
928             reqs_met = True
929             for req in config['requirements']:
930                 reqs_met, reason = req()
931                 logging.debug("Test %d: Requirement '%s' met? %s", config['test_id'], reason,
932                               reqs_met)
933                 if not reqs_met:
934                     break
935             if not reqs_met:
936                 print("Test {0} SKIPPED ({1}) {2}".format(config['test_id'], reason, desc))
937                 skipped = skipped + 1
938                 continue
939
940         test.setup(artifact_root, config['test_id'])
941         test.run()
942         test.check_result()
943         if test.passed:
944             result = "PASSED"
945             passed = passed + 1
946         else:
947             result = "FAILED: {0}".format(test.failure_reason)
948             failed = failed + 1
949             contents, _ = FioJobTest.get_file(test.stderr_file)
950             logging.debug("Test %d: stderr:\n%s", config['test_id'], contents)
951             contents, _ = FioJobTest.get_file(test.stdout_file)
952             logging.debug("Test %d: stdout:\n%s", config['test_id'], contents)
953         print("Test {0} {1} {2}".format(config['test_id'], result, desc))
954
955     print("{0} test(s) passed, {1} failed, {2} skipped".format(passed, failed, skipped))
956
957     sys.exit(failed)
958
959
960 if __name__ == '__main__':
961     main()