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