Add a test that sets gtod_cpu=1
[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 < 995 or iops1 > 1005:
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_iops_rate,
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':          12,
680         'test_class':       FioJobTest_iops_rate,
681         'job':              't0012.fio',
682         'success':          SUCCESS_DEFAULT,
683         'pre_job':          None,
684         'pre_success':      None,
685         'output_format':    'json',
686         'requirements':     [],
687         'requirements':     [Requirements.not_macos],
688         # mac os does not support CPU affinity
689     },
690     {
691         'test_id':          1000,
692         'test_class':       FioExeTest,
693         'exe':              't/axmap',
694         'parameters':       None,
695         'success':          SUCCESS_DEFAULT,
696         'requirements':     [],
697     },
698     {
699         'test_id':          1001,
700         'test_class':       FioExeTest,
701         'exe':              't/ieee754',
702         'parameters':       None,
703         'success':          SUCCESS_DEFAULT,
704         'requirements':     [],
705     },
706     {
707         'test_id':          1002,
708         'test_class':       FioExeTest,
709         'exe':              't/lfsr-test',
710         'parameters':       ['0xFFFFFF', '0', '0', 'verify'],
711         'success':          SUCCESS_STDERR,
712         'requirements':     [],
713     },
714     {
715         'test_id':          1003,
716         'test_class':       FioExeTest,
717         'exe':              't/readonly.py',
718         'parameters':       ['-f', '{fio_path}'],
719         'success':          SUCCESS_DEFAULT,
720         'requirements':     [],
721     },
722     {
723         'test_id':          1004,
724         'test_class':       FioExeTest,
725         'exe':              't/steadystate_tests.py',
726         'parameters':       ['{fio_path}'],
727         'success':          SUCCESS_DEFAULT,
728         'requirements':     [],
729     },
730     {
731         'test_id':          1005,
732         'test_class':       FioExeTest,
733         'exe':              't/stest',
734         'parameters':       None,
735         'success':          SUCCESS_STDERR,
736         'requirements':     [],
737     },
738     {
739         'test_id':          1006,
740         'test_class':       FioExeTest,
741         'exe':              't/strided.py',
742         'parameters':       ['{fio_path}'],
743         'success':          SUCCESS_DEFAULT,
744         'requirements':     [],
745     },
746     {
747         'test_id':          1007,
748         'test_class':       FioExeTest,
749         'exe':              't/zbd/run-tests-against-regular-nullb',
750         'parameters':       None,
751         'success':          SUCCESS_DEFAULT,
752         'requirements':     [Requirements.linux, Requirements.zbd,
753                              Requirements.root],
754     },
755     {
756         'test_id':          1008,
757         'test_class':       FioExeTest,
758         'exe':              't/zbd/run-tests-against-zoned-nullb',
759         'parameters':       None,
760         'success':          SUCCESS_DEFAULT,
761         'requirements':     [Requirements.linux, Requirements.zbd,
762                              Requirements.root, Requirements.zoned_nullb],
763     },
764     {
765         'test_id':          1009,
766         'test_class':       FioExeTest,
767         'exe':              'unittests/unittest',
768         'parameters':       None,
769         'success':          SUCCESS_DEFAULT,
770         'requirements':     [Requirements.unittests],
771     },
772     {
773         'test_id':          1010,
774         'test_class':       FioExeTest,
775         'exe':              't/latency_percentiles.py',
776         'parameters':       ['-f', '{fio_path}'],
777         'success':          SUCCESS_DEFAULT,
778         'requirements':     [],
779     },
780     {
781         'test_id':          1011,
782         'test_class':       FioExeTest,
783         'exe':              't/jsonplus2csv_test.py',
784         'parameters':       ['-f', '{fio_path}'],
785         'success':          SUCCESS_DEFAULT,
786         'requirements':     [],
787     },
788 ]
789
790
791 def parse_args():
792     """Parse command-line arguments."""
793
794     parser = argparse.ArgumentParser()
795     parser.add_argument('-r', '--fio-root',
796                         help='fio root path')
797     parser.add_argument('-f', '--fio',
798                         help='path to fio executable (e.g., ./fio)')
799     parser.add_argument('-a', '--artifact-root',
800                         help='artifact root directory')
801     parser.add_argument('-s', '--skip', nargs='+', type=int,
802                         help='list of test(s) to skip')
803     parser.add_argument('-o', '--run-only', nargs='+', type=int,
804                         help='list of test(s) to run, skipping all others')
805     parser.add_argument('-d', '--debug', action='store_true',
806                         help='provide debug output')
807     parser.add_argument('-k', '--skip-req', action='store_true',
808                         help='skip requirements checking')
809     parser.add_argument('-p', '--pass-through', action='append',
810                         help='pass-through an argument to an executable test')
811     args = parser.parse_args()
812
813     return args
814
815
816 def main():
817     """Entry point."""
818
819     args = parse_args()
820     if args.debug:
821         logging.basicConfig(level=logging.DEBUG)
822     else:
823         logging.basicConfig(level=logging.INFO)
824
825     pass_through = {}
826     if args.pass_through:
827         for arg in args.pass_through:
828             if not ':' in arg:
829                 print("Invalid --pass-through argument '%s'" % arg)
830                 print("Syntax for --pass-through is TESTNUMBER:ARGUMENT")
831                 return
832             split = arg.split(":",1)
833             pass_through[int(split[0])] = split[1]
834         logging.debug("Pass-through arguments: %s" % pass_through)
835
836     if args.fio_root:
837         fio_root = args.fio_root
838     else:
839         fio_root = str(Path(__file__).absolute().parent.parent)
840     print("fio root is %s" % fio_root)
841
842     if args.fio:
843         fio_path = args.fio
844     else:
845         if platform.system() == "Windows":
846             fio_exe = "fio.exe"
847         else:
848             fio_exe = "fio"
849         fio_path = os.path.join(fio_root, fio_exe)
850     print("fio path is %s" % fio_path)
851     if not shutil.which(fio_path):
852         print("Warning: fio executable not found")
853
854     artifact_root = args.artifact_root if args.artifact_root else \
855         "fio-test-{0}".format(time.strftime("%Y%m%d-%H%M%S"))
856     os.mkdir(artifact_root)
857     print("Artifact directory is %s" % artifact_root)
858
859     if not args.skip_req:
860         req = Requirements(fio_root)
861
862     passed = 0
863     failed = 0
864     skipped = 0
865
866     for config in TEST_LIST:
867         if (args.skip and config['test_id'] in args.skip) or \
868            (args.run_only and config['test_id'] not in args.run_only):
869             skipped = skipped + 1
870             print("Test {0} SKIPPED (User request)".format(config['test_id']))
871             continue
872
873         if issubclass(config['test_class'], FioJobTest):
874             if config['pre_job']:
875                 fio_pre_job = os.path.join(fio_root, 't', 'jobs',
876                                            config['pre_job'])
877             else:
878                 fio_pre_job = None
879             if config['pre_success']:
880                 fio_pre_success = config['pre_success']
881             else:
882                 fio_pre_success = None
883             if 'output_format' in config:
884                 output_format = config['output_format']
885             else:
886                 output_format = 'normal'
887             test = config['test_class'](
888                 fio_path,
889                 os.path.join(fio_root, 't', 'jobs', config['job']),
890                 config['success'],
891                 fio_pre_job=fio_pre_job,
892                 fio_pre_success=fio_pre_success,
893                 output_format=output_format)
894         elif issubclass(config['test_class'], FioExeTest):
895             exe_path = os.path.join(fio_root, config['exe'])
896             if config['parameters']:
897                 parameters = [p.format(fio_path=fio_path) for p in config['parameters']]
898             else:
899                 parameters = []
900             if Path(exe_path).suffix == '.py' and platform.system() == "Windows":
901                 parameters.insert(0, exe_path)
902                 exe_path = "python.exe"
903             if config['test_id'] in pass_through:
904                 parameters += pass_through[config['test_id']].split()
905             test = config['test_class'](exe_path, parameters,
906                                         config['success'])
907         else:
908             print("Test {0} FAILED: unable to process test config".format(config['test_id']))
909             failed = failed + 1
910             continue
911
912         if not args.skip_req:
913             reqs_met = True
914             for req in config['requirements']:
915                 reqs_met, reason = req()
916                 logging.debug("Test %d: Requirement '%s' met? %s", config['test_id'], reason,
917                               reqs_met)
918                 if not reqs_met:
919                     break
920             if not reqs_met:
921                 print("Test {0} SKIPPED ({1})".format(config['test_id'], reason))
922                 skipped = skipped + 1
923                 continue
924
925         test.setup(artifact_root, config['test_id'])
926         test.run()
927         test.check_result()
928         if test.passed:
929             result = "PASSED"
930             passed = passed + 1
931         else:
932             result = "FAILED: {0}".format(test.failure_reason)
933             failed = failed + 1
934             contents, _ = FioJobTest.get_file(test.stderr_file)
935             logging.debug("Test %d: stderr:\n%s", config['test_id'], contents)
936             contents, _ = FioJobTest.get_file(test.stdout_file)
937             logging.debug("Test %d: stdout:\n%s", config['test_id'], contents)
938         print("Test {0} {1}".format(config['test_id'], result))
939
940     print("{0} test(s) passed, {1} failed, {2} skipped".format(passed, failed, skipped))
941
942     sys.exit(failed)
943
944
945 if __name__ == '__main__':
946     main()