Merge branch 'master' of https://github.com/celestinechen/fio
[fio.git] / t / run-fio-tests.py
1 #!/usr/bin/env python3
2 # SPDX-License-Identifier: GPL-2.0-only
3 #
4 # Copyright (c) 2019 Western Digital Corporation or its affiliates.
5 #
6 """
7 # run-fio-tests.py
8 #
9 # Automate running of fio tests
10 #
11 # USAGE
12 # python3 run-fio-tests.py [-r fio-root] [-f fio-path] [-a artifact-root]
13 #                           [--skip # # #...] [--run-only # # #...]
14 #
15 #
16 # EXAMPLE
17 # # git clone git://git.kernel.dk/fio.git
18 # # cd fio
19 # # make -j
20 # # python3 t/run-fio-tests.py
21 #
22 #
23 # REQUIREMENTS
24 # - Python 3.5 (subprocess.run)
25 # - Linux (libaio ioengine, zbd tests, etc)
26 # - The artifact directory must be on a file system that accepts 512-byte IO
27 #   (t0002, t0003, t0004).
28 # - The artifact directory needs to be on an SSD. Otherwise tests that carry
29 #   out file-based IO will trigger a timeout (t0006).
30 # - 4 CPUs (t0009)
31 # - SciPy (steadystate_tests.py)
32 # - libzbc (zbd tests)
33 # - root privileges (zbd test)
34 # - kernel 4.19 or later for zoned null block devices (zbd tests)
35 # - CUnit support (unittests)
36 #
37 """
38
39 #
40 # TODO  run multiple tests simultaneously
41 # TODO  Add sgunmap tests (requires SAS SSD)
42 #
43
44 import os
45 import sys
46 import json
47 import time
48 import shutil
49 import logging
50 import argparse
51 import platform
52 import subprocess
53 import multiprocessing
54 from pathlib import Path
55
56
57 class FioTest(object):
58     """Base for all fio tests."""
59
60     def __init__(self, exe_path, parameters, success):
61         self.exe_path = exe_path
62         self.parameters = parameters
63         self.success = success
64         self.output = {}
65         self.artifact_root = None
66         self.testnum = None
67         self.test_dir = None
68         self.passed = True
69         self.failure_reason = ''
70         self.command_file = None
71         self.stdout_file = None
72         self.stderr_file = None
73         self.exitcode_file = None
74
75     def setup(self, artifact_root, testnum):
76         """Setup instance variables for test."""
77
78         self.artifact_root = artifact_root
79         self.testnum = testnum
80         self.test_dir = os.path.join(artifact_root, "{:04d}".format(testnum))
81         if not os.path.exists(self.test_dir):
82             os.mkdir(self.test_dir)
83
84         self.command_file = os.path.join(
85             self.test_dir,
86             "{0}.command".format(os.path.basename(self.exe_path)))
87         self.stdout_file = os.path.join(
88             self.test_dir,
89             "{0}.stdout".format(os.path.basename(self.exe_path)))
90         self.stderr_file = os.path.join(
91             self.test_dir,
92             "{0}.stderr".format(os.path.basename(self.exe_path)))
93         self.exitcode_file = os.path.join(
94             self.test_dir,
95             "{0}.exitcode".format(os.path.basename(self.exe_path)))
96
97     def run(self):
98         """Run the test."""
99
100         raise NotImplementedError()
101
102     def check_result(self):
103         """Check test results."""
104
105         raise NotImplementedError()
106
107
108 class FioExeTest(FioTest):
109     """Test consists of an executable binary or script"""
110
111     def __init__(self, exe_path, parameters, success):
112         """Construct a FioExeTest which is a FioTest consisting of an
113         executable binary or script.
114
115         exe_path:       location of executable binary or script
116         parameters:     list of parameters for executable
117         success:        Definition of test success
118         """
119
120         FioTest.__init__(self, exe_path, parameters, success)
121
122     def run(self):
123         """Execute the binary or script described by this instance."""
124
125         command = [self.exe_path] + self.parameters
126         command_file = open(self.command_file, "w+")
127         command_file.write("%s\n" % command)
128         command_file.close()
129
130         stdout_file = open(self.stdout_file, "w+")
131         stderr_file = open(self.stderr_file, "w+")
132         exitcode_file = open(self.exitcode_file, "w+")
133         try:
134             proc = None
135             # Avoid using subprocess.run() here because when a timeout occurs,
136             # fio will be stopped with SIGKILL. This does not give fio a
137             # chance to clean up and means that child processes may continue
138             # running and submitting IO.
139             proc = subprocess.Popen(command,
140                                     stdout=stdout_file,
141                                     stderr=stderr_file,
142                                     cwd=self.test_dir,
143                                     universal_newlines=True)
144             proc.communicate(timeout=self.success['timeout'])
145             exitcode_file.write('{0}\n'.format(proc.returncode))
146             logging.debug("Test %d: return code: %d", self.testnum, proc.returncode)
147             self.output['proc'] = proc
148         except subprocess.TimeoutExpired:
149             proc.terminate()
150             proc.communicate()
151             assert proc.poll()
152             self.output['failure'] = 'timeout'
153         except Exception:
154             if proc:
155                 if not proc.poll():
156                     proc.terminate()
157                     proc.communicate()
158             self.output['failure'] = 'exception'
159             self.output['exc_info'] = sys.exc_info()
160         finally:
161             stdout_file.close()
162             stderr_file.close()
163             exitcode_file.close()
164
165     def check_result(self):
166         """Check results of test run."""
167
168         if 'proc' not in self.output:
169             if self.output['failure'] == 'timeout':
170                 self.failure_reason = "{0} timeout,".format(self.failure_reason)
171             else:
172                 assert self.output['failure'] == 'exception'
173                 self.failure_reason = '{0} exception: {1}, {2}'.format(
174                     self.failure_reason, self.output['exc_info'][0],
175                     self.output['exc_info'][1])
176
177             self.passed = False
178             return
179
180         if 'zero_return' in self.success:
181             if self.success['zero_return']:
182                 if self.output['proc'].returncode != 0:
183                     self.passed = False
184                     self.failure_reason = "{0} non-zero return code,".format(self.failure_reason)
185             else:
186                 if self.output['proc'].returncode == 0:
187                     self.failure_reason = "{0} zero return code,".format(self.failure_reason)
188                     self.passed = False
189
190         stderr_size = os.path.getsize(self.stderr_file)
191         if 'stderr_empty' in self.success:
192             if self.success['stderr_empty']:
193                 if stderr_size != 0:
194                     self.failure_reason = "{0} stderr not empty,".format(self.failure_reason)
195                     self.passed = False
196             else:
197                 if stderr_size == 0:
198                     self.failure_reason = "{0} stderr empty,".format(self.failure_reason)
199                     self.passed = False
200
201
202 class FioJobTest(FioExeTest):
203     """Test consists of a fio job"""
204
205     def __init__(self, fio_path, fio_job, success, fio_pre_job=None,
206                  fio_pre_success=None, output_format="normal"):
207         """Construct a FioJobTest which is a FioExeTest consisting of a
208         single fio job file with an optional setup step.
209
210         fio_path:           location of fio executable
211         fio_job:            location of fio job file
212         success:            Definition of test success
213         fio_pre_job:        fio job for preconditioning
214         fio_pre_success:    Definition of test success for fio precon job
215         output_format:      normal (default), json, jsonplus, or terse
216         """
217
218         self.fio_job = fio_job
219         self.fio_pre_job = fio_pre_job
220         self.fio_pre_success = fio_pre_success if fio_pre_success else success
221         self.output_format = output_format
222         self.precon_failed = False
223         self.json_data = None
224         self.fio_output = "{0}.output".format(os.path.basename(self.fio_job))
225         self.fio_args = [
226             "--max-jobs=16",
227             "--output-format={0}".format(self.output_format),
228             "--output={0}".format(self.fio_output),
229             self.fio_job,
230             ]
231         FioExeTest.__init__(self, fio_path, self.fio_args, success)
232
233     def setup(self, artifact_root, testnum):
234         """Setup instance variables for fio job test."""
235
236         super(FioJobTest, self).setup(artifact_root, testnum)
237
238         self.command_file = os.path.join(
239             self.test_dir,
240             "{0}.command".format(os.path.basename(self.fio_job)))
241         self.stdout_file = os.path.join(
242             self.test_dir,
243             "{0}.stdout".format(os.path.basename(self.fio_job)))
244         self.stderr_file = os.path.join(
245             self.test_dir,
246             "{0}.stderr".format(os.path.basename(self.fio_job)))
247         self.exitcode_file = os.path.join(
248             self.test_dir,
249             "{0}.exitcode".format(os.path.basename(self.fio_job)))
250
251     def run_pre_job(self):
252         """Run fio job precondition step."""
253
254         precon = FioJobTest(self.exe_path, self.fio_pre_job,
255                             self.fio_pre_success,
256                             output_format=self.output_format)
257         precon.setup(self.artifact_root, self.testnum)
258         precon.run()
259         precon.check_result()
260         self.precon_failed = not precon.passed
261         self.failure_reason = precon.failure_reason
262
263     def run(self):
264         """Run fio job test."""
265
266         if self.fio_pre_job:
267             self.run_pre_job()
268
269         if not self.precon_failed:
270             super(FioJobTest, self).run()
271         else:
272             logging.debug("Test %d: precondition step failed", self.testnum)
273
274     @classmethod
275     def get_file(cls, filename):
276         """Safely read a file."""
277         file_data = ''
278         success = True
279
280         try:
281             with open(filename, "r") as output_file:
282                 file_data = output_file.read()
283         except OSError:
284             success = False
285
286         return file_data, success
287
288     def check_result(self):
289         """Check fio job results."""
290
291         if self.precon_failed:
292             self.passed = False
293             self.failure_reason = "{0} precondition step failed,".format(self.failure_reason)
294             return
295
296         super(FioJobTest, self).check_result()
297
298         if not self.passed:
299             return
300
301         if 'json' not in self.output_format:
302             return
303
304         file_data, success = self.get_file(os.path.join(self.test_dir, self.fio_output))
305         if not success:
306             self.failure_reason = "{0} unable to open output file,".format(self.failure_reason)
307             self.passed = False
308             return
309
310         #
311         # Sometimes fio informational messages are included at the top of the
312         # JSON output, especially under Windows. Try to decode output as JSON
313         # data, lopping off up to the first four lines
314         #
315         lines = file_data.splitlines()
316         for i in range(5):
317             file_data = '\n'.join(lines[i:])
318             try:
319                 self.json_data = json.loads(file_data)
320             except json.JSONDecodeError:
321                 continue
322             else:
323                 logging.debug("Test %d: skipped %d lines decoding JSON data", self.testnum, i)
324                 return
325
326         self.failure_reason = "{0} unable to decode JSON data,".format(self.failure_reason)
327         self.passed = False
328
329
330 class FioJobTest_t0005(FioJobTest):
331     """Test consists of fio test job t0005
332     Confirm that read['io_kbytes'] == write['io_kbytes'] == 102400"""
333
334     def check_result(self):
335         super(FioJobTest_t0005, self).check_result()
336
337         if not self.passed:
338             return
339
340         if self.json_data['jobs'][0]['read']['io_kbytes'] != 102400:
341             self.failure_reason = "{0} bytes read mismatch,".format(self.failure_reason)
342             self.passed = False
343         if self.json_data['jobs'][0]['write']['io_kbytes'] != 102400:
344             self.failure_reason = "{0} bytes written mismatch,".format(self.failure_reason)
345             self.passed = False
346
347
348 class FioJobTest_t0006(FioJobTest):
349     """Test consists of fio test job t0006
350     Confirm that read['io_kbytes'] ~ 2*write['io_kbytes']"""
351
352     def check_result(self):
353         super(FioJobTest_t0006, self).check_result()
354
355         if not self.passed:
356             return
357
358         ratio = self.json_data['jobs'][0]['read']['io_kbytes'] \
359             / self.json_data['jobs'][0]['write']['io_kbytes']
360         logging.debug("Test %d: ratio: %f", self.testnum, ratio)
361         if ratio < 1.99 or ratio > 2.01:
362             self.failure_reason = "{0} read/write ratio mismatch,".format(self.failure_reason)
363             self.passed = False
364
365
366 class FioJobTest_t0007(FioJobTest):
367     """Test consists of fio test job t0007
368     Confirm that read['io_kbytes'] = 87040"""
369
370     def check_result(self):
371         super(FioJobTest_t0007, self).check_result()
372
373         if not self.passed:
374             return
375
376         if self.json_data['jobs'][0]['read']['io_kbytes'] != 87040:
377             self.failure_reason = "{0} bytes read mismatch,".format(self.failure_reason)
378             self.passed = False
379
380
381 class FioJobTest_t0008(FioJobTest):
382     """Test consists of fio test job t0008
383     Confirm that read['io_kbytes'] = 32768 and that
384                 write['io_kbytes'] ~ 16568
385
386     I did runs with fio-ae2fafc8 and saw write['io_kbytes'] values of
387     16585, 16588. With two runs of fio-3.16 I obtained 16568"""
388
389     def check_result(self):
390         super(FioJobTest_t0008, self).check_result()
391
392         if not self.passed:
393             return
394
395         ratio = self.json_data['jobs'][0]['write']['io_kbytes'] / 16568
396         logging.debug("Test %d: ratio: %f", self.testnum, ratio)
397
398         if ratio < 0.99 or ratio > 1.01:
399             self.failure_reason = "{0} bytes written mismatch,".format(self.failure_reason)
400             self.passed = False
401         if self.json_data['jobs'][0]['read']['io_kbytes'] != 32768:
402             self.failure_reason = "{0} bytes read mismatch,".format(self.failure_reason)
403             self.passed = False
404
405
406 class FioJobTest_t0009(FioJobTest):
407     """Test consists of fio test job t0009
408     Confirm that runtime >= 60s"""
409
410     def check_result(self):
411         super(FioJobTest_t0009, self).check_result()
412
413         if not self.passed:
414             return
415
416         logging.debug('Test %d: elapsed: %d', self.testnum, self.json_data['jobs'][0]['elapsed'])
417
418         if self.json_data['jobs'][0]['elapsed'] < 60:
419             self.failure_reason = "{0} elapsed time mismatch,".format(self.failure_reason)
420             self.passed = False
421
422
423 class FioJobTest_t0012(FioJobTest):
424     """Test consists of fio test job t0012
425     Confirm ratios of job iops are 1:5:10
426     job1,job2,job3 respectively"""
427
428     def check_result(self):
429         super(FioJobTest_t0012, self).check_result()
430
431         if not self.passed:
432             return
433
434         iops_files = []
435         for i in range(1,4):
436             file_data, success = self.get_file(os.path.join(self.test_dir, "{0}_iops.{1}.log".format(os.path.basename(self.fio_job), i)))
437
438             if not success:
439                 self.failure_reason = "{0} unable to open output file,".format(self.failure_reason)
440                 self.passed = False
441                 return
442
443             iops_files.append(file_data.splitlines())
444
445         # there are 9 samples for job1 and job2, 4 samples for job3
446         iops1 = 0.0
447         iops2 = 0.0
448         iops3 = 0.0
449         for i in range(9):
450             iops1 = iops1 + float(iops_files[0][i].split(',')[1])
451             iops2 = iops2 + float(iops_files[1][i].split(',')[1])
452             iops3 = iops3 + float(iops_files[2][i].split(',')[1])
453
454             ratio1 = iops3/iops2
455             ratio2 = iops3/iops1
456             logging.debug(
457                 "sample {0}: job1 iops={1} job2 iops={2} job3 iops={3} job3/job2={4:.3f} job3/job1={5:.3f}".format(
458                     i, iops1, iops2, iops3, ratio1, ratio2
459                 )
460             )
461
462         # test job1 and job2 succeeded to recalibrate
463         if ratio1 < 1 or ratio1 > 3 or ratio2 < 7 or ratio2 > 13:
464             self.failure_reason = "{0} iops ratio mismatch iops1={1} iops2={2} iops3={3} expected r1~2 r2~10 got r1={4:.3f} r2={5:.3f},".format(
465                 self.failure_reason, iops1, iops2, iops3, ratio1, ratio2
466             )
467             self.passed = False
468             return
469
470
471 class FioJobTest_t0014(FioJobTest):
472     """Test consists of fio test job t0014
473         Confirm that job1_iops / job2_iops ~ 1:2 for entire duration
474         and that job1_iops / job3_iops ~ 1:3 for first half of duration.
475
476     The test is about making sure the flow feature can
477     re-calibrate the activity dynamically"""
478
479     def check_result(self):
480         super(FioJobTest_t0014, self).check_result()
481
482         if not self.passed:
483             return
484
485         iops_files = []
486         for i in range(1,4):
487             file_data, success = self.get_file(os.path.join(self.test_dir, "{0}_iops.{1}.log".format(os.path.basename(self.fio_job), i)))
488
489             if not success:
490                 self.failure_reason = "{0} unable to open output file,".format(self.failure_reason)
491                 self.passed = False
492                 return
493
494             iops_files.append(file_data.splitlines())
495
496         # there are 9 samples for job1 and job2, 4 samples for job3
497         iops1 = 0.0
498         iops2 = 0.0
499         iops3 = 0.0
500         for i in range(9):
501             if i < 4:
502                 iops3 = iops3 + float(iops_files[2][i].split(',')[1])
503             elif i == 4:
504                 ratio1 = iops1 / iops2
505                 ratio2 = iops1 / iops3
506
507
508                 if ratio1 < 0.43 or ratio1 > 0.57 or ratio2 < 0.21 or ratio2 > 0.45:
509                     self.failure_reason = "{0} iops ratio mismatch iops1={1} iops2={2} iops3={3}\
510                                                 expected r1~0.5 r2~0.33 got r1={4:.3f} r2={5:.3f},".format(
511                         self.failure_reason, iops1, iops2, iops3, ratio1, ratio2
512                     )
513                     self.passed = False
514
515             iops1 = iops1 + float(iops_files[0][i].split(',')[1])
516             iops2 = iops2 + float(iops_files[1][i].split(',')[1])
517
518             ratio1 = iops1/iops2
519             ratio2 = iops1/iops3
520             logging.debug(
521                 "sample {0}: job1 iops={1} job2 iops={2} job3 iops={3} job1/job2={4:.3f} job1/job3={5:.3f}".format(
522                     i, iops1, iops2, iops3, ratio1, ratio2
523                 )
524             )
525
526         # test job1 and job2 succeeded to recalibrate
527         if ratio1 < 0.43 or ratio1 > 0.57:
528             self.failure_reason = "{0} iops ratio mismatch iops1={1} iops2={2} expected ratio~0.5 got ratio={3:.3f},".format(
529                 self.failure_reason, iops1, iops2, ratio1
530             )
531             self.passed = False
532             return
533
534
535 class FioJobTest_iops_rate(FioJobTest):
536     """Test consists of fio test job t0009
537     Confirm that job0 iops == 1000
538     and that job1_iops / job0_iops ~ 8
539     With two runs of fio-3.16 I observed a ratio of 8.3"""
540
541     def check_result(self):
542         super(FioJobTest_iops_rate, self).check_result()
543
544         if not self.passed:
545             return
546
547         iops1 = self.json_data['jobs'][0]['read']['iops']
548         iops2 = self.json_data['jobs'][1]['read']['iops']
549         ratio = iops2 / iops1
550         logging.debug("Test %d: iops1: %f", self.testnum, iops1)
551         logging.debug("Test %d: ratio: %f", self.testnum, ratio)
552
553         if iops1 < 950 or iops1 > 1050:
554             self.failure_reason = "{0} iops value mismatch,".format(self.failure_reason)
555             self.passed = False
556
557         if ratio < 6 or ratio > 10:
558             self.failure_reason = "{0} iops ratio mismatch,".format(self.failure_reason)
559             self.passed = False
560
561
562 class Requirements(object):
563     """Requirements consists of multiple run environment characteristics.
564     These are to determine if a particular test can be run"""
565
566     _linux = False
567     _libaio = False
568     _zbd = False
569     _root = False
570     _zoned_nullb = False
571     _not_macos = False
572     _not_windows = False
573     _unittests = False
574     _cpucount4 = False
575
576     def __init__(self, fio_root):
577         Requirements._not_macos = platform.system() != "Darwin"
578         Requirements._not_windows = platform.system() != "Windows"
579         Requirements._linux = platform.system() == "Linux"
580
581         if Requirements._linux:
582             config_file = os.path.join(fio_root, "config-host.h")
583             contents, success = FioJobTest.get_file(config_file)
584             if not success:
585                 print("Unable to open {0} to check requirements".format(config_file))
586                 Requirements._zbd = True
587             else:
588                 Requirements._zbd = "CONFIG_HAS_BLKZONED" in contents
589                 Requirements._libaio = "CONFIG_LIBAIO" in contents
590
591             Requirements._root = (os.geteuid() == 0)
592             if Requirements._zbd and Requirements._root:
593                 try:
594                     subprocess.run(["modprobe", "null_blk"],
595                                    stdout=subprocess.PIPE,
596                                    stderr=subprocess.PIPE)
597                     if os.path.exists("/sys/module/null_blk/parameters/zoned"):
598                         Requirements._zoned_nullb = True
599                 except Exception:
600                     pass
601
602         if platform.system() == "Windows":
603             utest_exe = "unittest.exe"
604         else:
605             utest_exe = "unittest"
606         unittest_path = os.path.join(fio_root, "unittests", utest_exe)
607         Requirements._unittests = os.path.exists(unittest_path)
608
609         Requirements._cpucount4 = multiprocessing.cpu_count() >= 4
610
611         req_list = [Requirements.linux,
612                     Requirements.libaio,
613                     Requirements.zbd,
614                     Requirements.root,
615                     Requirements.zoned_nullb,
616                     Requirements.not_macos,
617                     Requirements.not_windows,
618                     Requirements.unittests,
619                     Requirements.cpucount4]
620         for req in req_list:
621             value, desc = req()
622             logging.debug("Requirements: Requirement '%s' met? %s", desc, value)
623
624     @classmethod
625     def linux(cls):
626         """Are we running on Linux?"""
627         return Requirements._linux, "Linux required"
628
629     @classmethod
630     def libaio(cls):
631         """Is libaio available?"""
632         return Requirements._libaio, "libaio required"
633
634     @classmethod
635     def zbd(cls):
636         """Is ZBD support available?"""
637         return Requirements._zbd, "Zoned block device support required"
638
639     @classmethod
640     def root(cls):
641         """Are we running as root?"""
642         return Requirements._root, "root required"
643
644     @classmethod
645     def zoned_nullb(cls):
646         """Are zoned null block devices available?"""
647         return Requirements._zoned_nullb, "Zoned null block device support required"
648
649     @classmethod
650     def not_macos(cls):
651         """Are we running on a platform other than macOS?"""
652         return Requirements._not_macos, "platform other than macOS required"
653
654     @classmethod
655     def not_windows(cls):
656         """Are we running on a platform other than Windws?"""
657         return Requirements._not_windows, "platform other than Windows required"
658
659     @classmethod
660     def unittests(cls):
661         """Were unittests built?"""
662         return Requirements._unittests, "Unittests support required"
663
664     @classmethod
665     def cpucount4(cls):
666         """Do we have at least 4 CPUs?"""
667         return Requirements._cpucount4, "4+ CPUs required"
668
669
670 SUCCESS_DEFAULT = {
671     'zero_return': True,
672     'stderr_empty': True,
673     'timeout': 600,
674     }
675 SUCCESS_NONZERO = {
676     'zero_return': False,
677     'stderr_empty': False,
678     'timeout': 600,
679     }
680 SUCCESS_STDERR = {
681     'zero_return': True,
682     'stderr_empty': False,
683     'timeout': 600,
684     }
685 TEST_LIST = [
686     {
687         'test_id':          1,
688         'test_class':       FioJobTest,
689         'job':              't0001-52c58027.fio',
690         'success':          SUCCESS_DEFAULT,
691         'pre_job':          None,
692         'pre_success':      None,
693         'requirements':     [],
694     },
695     {
696         'test_id':          2,
697         'test_class':       FioJobTest,
698         'job':              't0002-13af05ae-post.fio',
699         'success':          SUCCESS_DEFAULT,
700         'pre_job':          't0002-13af05ae-pre.fio',
701         'pre_success':      None,
702         'requirements':     [Requirements.linux, Requirements.libaio],
703     },
704     {
705         'test_id':          3,
706         'test_class':       FioJobTest,
707         'job':              't0003-0ae2c6e1-post.fio',
708         'success':          SUCCESS_NONZERO,
709         'pre_job':          't0003-0ae2c6e1-pre.fio',
710         'pre_success':      SUCCESS_DEFAULT,
711         'requirements':     [Requirements.linux, Requirements.libaio],
712     },
713     {
714         'test_id':          4,
715         'test_class':       FioJobTest,
716         'job':              't0004-8a99fdf6.fio',
717         'success':          SUCCESS_DEFAULT,
718         'pre_job':          None,
719         'pre_success':      None,
720         'requirements':     [Requirements.linux, Requirements.libaio],
721     },
722     {
723         'test_id':          5,
724         'test_class':       FioJobTest_t0005,
725         'job':              't0005-f7078f7b.fio',
726         'success':          SUCCESS_DEFAULT,
727         'pre_job':          None,
728         'pre_success':      None,
729         'output_format':    'json',
730         'requirements':     [Requirements.not_windows],
731     },
732     {
733         'test_id':          6,
734         'test_class':       FioJobTest_t0006,
735         'job':              't0006-82af2a7c.fio',
736         'success':          SUCCESS_DEFAULT,
737         'pre_job':          None,
738         'pre_success':      None,
739         'output_format':    'json',
740         'requirements':     [Requirements.linux, Requirements.libaio],
741     },
742     {
743         'test_id':          7,
744         'test_class':       FioJobTest_t0007,
745         'job':              't0007-37cf9e3c.fio',
746         'success':          SUCCESS_DEFAULT,
747         'pre_job':          None,
748         'pre_success':      None,
749         'output_format':    'json',
750         'requirements':     [],
751     },
752     {
753         'test_id':          8,
754         'test_class':       FioJobTest_t0008,
755         'job':              't0008-ae2fafc8.fio',
756         'success':          SUCCESS_DEFAULT,
757         'pre_job':          None,
758         'pre_success':      None,
759         'output_format':    'json',
760         'requirements':     [],
761     },
762     {
763         'test_id':          9,
764         'test_class':       FioJobTest_t0009,
765         'job':              't0009-f8b0bd10.fio',
766         'success':          SUCCESS_DEFAULT,
767         'pre_job':          None,
768         'pre_success':      None,
769         'output_format':    'json',
770         'requirements':     [Requirements.not_macos,
771                              Requirements.cpucount4],
772         # mac os does not support CPU affinity
773     },
774     {
775         'test_id':          10,
776         'test_class':       FioJobTest,
777         'job':              't0010-b7aae4ba.fio',
778         'success':          SUCCESS_DEFAULT,
779         'pre_job':          None,
780         'pre_success':      None,
781         'requirements':     [],
782     },
783     {
784         'test_id':          11,
785         'test_class':       FioJobTest_iops_rate,
786         'job':              't0011-5d2788d5.fio',
787         'success':          SUCCESS_DEFAULT,
788         'pre_job':          None,
789         'pre_success':      None,
790         'output_format':    'json',
791         'requirements':     [],
792     },
793     {
794         'test_id':          12,
795         'test_class':       FioJobTest_t0012,
796         'job':              't0012.fio',
797         'success':          SUCCESS_DEFAULT,
798         'pre_job':          None,
799         'pre_success':      None,
800         'output_format':    'json',
801         'requirements':     [],
802     },
803     {
804         'test_id':          13,
805         'test_class':       FioJobTest,
806         'job':              't0013.fio',
807         'success':          SUCCESS_DEFAULT,
808         'pre_job':          None,
809         'pre_success':      None,
810         'output_format':    'json',
811         'requirements':     [],
812     },
813     {
814         'test_id':          14,
815         'test_class':       FioJobTest_t0014,
816         'job':              't0014.fio',
817         'success':          SUCCESS_DEFAULT,
818         'pre_job':          None,
819         'pre_success':      None,
820         'output_format':    'json',
821         'requirements':     [],
822     },
823     {
824         'test_id':          1000,
825         'test_class':       FioExeTest,
826         'exe':              't/axmap',
827         'parameters':       None,
828         'success':          SUCCESS_DEFAULT,
829         'requirements':     [],
830     },
831     {
832         'test_id':          1001,
833         'test_class':       FioExeTest,
834         'exe':              't/ieee754',
835         'parameters':       None,
836         'success':          SUCCESS_DEFAULT,
837         'requirements':     [],
838     },
839     {
840         'test_id':          1002,
841         'test_class':       FioExeTest,
842         'exe':              't/lfsr-test',
843         'parameters':       ['0xFFFFFF', '0', '0', 'verify'],
844         'success':          SUCCESS_STDERR,
845         'requirements':     [],
846     },
847     {
848         'test_id':          1003,
849         'test_class':       FioExeTest,
850         'exe':              't/readonly.py',
851         'parameters':       ['-f', '{fio_path}'],
852         'success':          SUCCESS_DEFAULT,
853         'requirements':     [],
854     },
855     {
856         'test_id':          1004,
857         'test_class':       FioExeTest,
858         'exe':              't/steadystate_tests.py',
859         'parameters':       ['{fio_path}'],
860         'success':          SUCCESS_DEFAULT,
861         'requirements':     [],
862     },
863     {
864         'test_id':          1005,
865         'test_class':       FioExeTest,
866         'exe':              't/stest',
867         'parameters':       None,
868         'success':          SUCCESS_STDERR,
869         'requirements':     [],
870     },
871     {
872         'test_id':          1006,
873         'test_class':       FioExeTest,
874         'exe':              't/strided.py',
875         'parameters':       ['{fio_path}'],
876         'success':          SUCCESS_DEFAULT,
877         'requirements':     [],
878     },
879     {
880         'test_id':          1007,
881         'test_class':       FioExeTest,
882         'exe':              't/zbd/run-tests-against-regular-nullb',
883         'parameters':       None,
884         'success':          SUCCESS_DEFAULT,
885         'requirements':     [Requirements.linux, Requirements.zbd,
886                              Requirements.root],
887     },
888     {
889         'test_id':          1008,
890         'test_class':       FioExeTest,
891         'exe':              't/zbd/run-tests-against-zoned-nullb',
892         'parameters':       None,
893         'success':          SUCCESS_DEFAULT,
894         'requirements':     [Requirements.linux, Requirements.zbd,
895                              Requirements.root, Requirements.zoned_nullb],
896     },
897     {
898         'test_id':          1009,
899         'test_class':       FioExeTest,
900         'exe':              'unittests/unittest',
901         'parameters':       None,
902         'success':          SUCCESS_DEFAULT,
903         'requirements':     [Requirements.unittests],
904     },
905     {
906         'test_id':          1010,
907         'test_class':       FioExeTest,
908         'exe':              't/latency_percentiles.py',
909         'parameters':       ['-f', '{fio_path}'],
910         'success':          SUCCESS_DEFAULT,
911         'requirements':     [],
912     },
913     {
914         'test_id':          1011,
915         'test_class':       FioExeTest,
916         'exe':              't/jsonplus2csv_test.py',
917         'parameters':       ['-f', '{fio_path}'],
918         'success':          SUCCESS_DEFAULT,
919         'requirements':     [],
920     },
921 ]
922
923
924 def parse_args():
925     """Parse command-line arguments."""
926
927     parser = argparse.ArgumentParser()
928     parser.add_argument('-r', '--fio-root',
929                         help='fio root path')
930     parser.add_argument('-f', '--fio',
931                         help='path to fio executable (e.g., ./fio)')
932     parser.add_argument('-a', '--artifact-root',
933                         help='artifact root directory')
934     parser.add_argument('-s', '--skip', nargs='+', type=int,
935                         help='list of test(s) to skip')
936     parser.add_argument('-o', '--run-only', nargs='+', type=int,
937                         help='list of test(s) to run, skipping all others')
938     parser.add_argument('-d', '--debug', action='store_true',
939                         help='provide debug output')
940     parser.add_argument('-k', '--skip-req', action='store_true',
941                         help='skip requirements checking')
942     parser.add_argument('-p', '--pass-through', action='append',
943                         help='pass-through an argument to an executable test')
944     args = parser.parse_args()
945
946     return args
947
948
949 def main():
950     """Entry point."""
951
952     args = parse_args()
953     if args.debug:
954         logging.basicConfig(level=logging.DEBUG)
955     else:
956         logging.basicConfig(level=logging.INFO)
957
958     pass_through = {}
959     if args.pass_through:
960         for arg in args.pass_through:
961             if not ':' in arg:
962                 print("Invalid --pass-through argument '%s'" % arg)
963                 print("Syntax for --pass-through is TESTNUMBER:ARGUMENT")
964                 return
965             split = arg.split(":", 1)
966             pass_through[int(split[0])] = split[1]
967         logging.debug("Pass-through arguments: %s", pass_through)
968
969     if args.fio_root:
970         fio_root = args.fio_root
971     else:
972         fio_root = str(Path(__file__).absolute().parent.parent)
973     print("fio root is %s" % fio_root)
974
975     if args.fio:
976         fio_path = args.fio
977     else:
978         if platform.system() == "Windows":
979             fio_exe = "fio.exe"
980         else:
981             fio_exe = "fio"
982         fio_path = os.path.join(fio_root, fio_exe)
983     print("fio path is %s" % fio_path)
984     if not shutil.which(fio_path):
985         print("Warning: fio executable not found")
986
987     artifact_root = args.artifact_root if args.artifact_root else \
988         "fio-test-{0}".format(time.strftime("%Y%m%d-%H%M%S"))
989     os.mkdir(artifact_root)
990     print("Artifact directory is %s" % artifact_root)
991
992     if not args.skip_req:
993         req = Requirements(fio_root)
994
995     passed = 0
996     failed = 0
997     skipped = 0
998
999     for config in TEST_LIST:
1000         if (args.skip and config['test_id'] in args.skip) or \
1001            (args.run_only and config['test_id'] not in args.run_only):
1002             skipped = skipped + 1
1003             print("Test {0} SKIPPED (User request)".format(config['test_id']))
1004             continue
1005
1006         if issubclass(config['test_class'], FioJobTest):
1007             if config['pre_job']:
1008                 fio_pre_job = os.path.join(fio_root, 't', 'jobs',
1009                                            config['pre_job'])
1010             else:
1011                 fio_pre_job = None
1012             if config['pre_success']:
1013                 fio_pre_success = config['pre_success']
1014             else:
1015                 fio_pre_success = None
1016             if 'output_format' in config:
1017                 output_format = config['output_format']
1018             else:
1019                 output_format = 'normal'
1020             test = config['test_class'](
1021                 fio_path,
1022                 os.path.join(fio_root, 't', 'jobs', config['job']),
1023                 config['success'],
1024                 fio_pre_job=fio_pre_job,
1025                 fio_pre_success=fio_pre_success,
1026                 output_format=output_format)
1027             desc = config['job']
1028         elif issubclass(config['test_class'], FioExeTest):
1029             exe_path = os.path.join(fio_root, config['exe'])
1030             if config['parameters']:
1031                 parameters = [p.format(fio_path=fio_path) for p in config['parameters']]
1032             else:
1033                 parameters = []
1034             if Path(exe_path).suffix == '.py' and platform.system() == "Windows":
1035                 parameters.insert(0, exe_path)
1036                 exe_path = "python.exe"
1037             if config['test_id'] in pass_through:
1038                 parameters += pass_through[config['test_id']].split()
1039             test = config['test_class'](exe_path, parameters,
1040                                         config['success'])
1041             desc = config['exe']
1042         else:
1043             print("Test {0} FAILED: unable to process test config".format(config['test_id']))
1044             failed = failed + 1
1045             continue
1046
1047         if not args.skip_req:
1048             reqs_met = True
1049             for req in config['requirements']:
1050                 reqs_met, reason = req()
1051                 logging.debug("Test %d: Requirement '%s' met? %s", config['test_id'], reason,
1052                               reqs_met)
1053                 if not reqs_met:
1054                     break
1055             if not reqs_met:
1056                 print("Test {0} SKIPPED ({1}) {2}".format(config['test_id'], reason, desc))
1057                 skipped = skipped + 1
1058                 continue
1059
1060         test.setup(artifact_root, config['test_id'])
1061         test.run()
1062         test.check_result()
1063         if test.passed:
1064             result = "PASSED"
1065             passed = passed + 1
1066         else:
1067             result = "FAILED: {0}".format(test.failure_reason)
1068             failed = failed + 1
1069             contents, _ = FioJobTest.get_file(test.stderr_file)
1070             logging.debug("Test %d: stderr:\n%s", config['test_id'], contents)
1071             contents, _ = FioJobTest.get_file(test.stdout_file)
1072             logging.debug("Test %d: stdout:\n%s", config['test_id'], contents)
1073         print("Test {0} {1} {2}".format(config['test_id'], result, desc))
1074
1075     print("{0} test(s) passed, {1} failed, {2} skipped".format(passed, failed, skipped))
1076
1077     sys.exit(failed)
1078
1079
1080 if __name__ == '__main__':
1081     main()