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