913cb6058f5ac67129e3bd885b0ba996542fb9b9
[fio.git] / t / fiotestlib.py
1 #!/usr/bin/env python3
2 """
3 fiotestlib.py
4
5 This library contains FioTest objects that provide convenient means to run
6 different sorts of fio tests.
7
8 It also contains a test runner that runs an array of dictionary objects
9 describing fio tests.
10 """
11
12 import os
13 import sys
14 import json
15 import locale
16 import logging
17 import platform
18 import traceback
19 import subprocess
20 from pathlib import Path
21 from fiotestcommon import get_file, SUCCESS_DEFAULT
22
23
24 class FioTest():
25     """Base for all fio tests."""
26
27     def __init__(self, exe_path, success, testnum, artifact_root):
28         self.success = success
29         self.testnum = testnum
30         self.output = {}
31         self.passed = True
32         self.failure_reason = ''
33         self.parameters = None
34         self.paths = {
35                         'exe': exe_path,
36                         'artifacts': artifact_root,
37                         'test_dir': os.path.join(artifact_root, \
38                                 f"{testnum:04d}"),
39                         }
40         self.filenames = {
41                             'cmd': os.path.join(self.paths['test_dir'], \
42                                     f"{os.path.basename(self.paths['exe'])}.command"),
43                             'stdout': os.path.join(self.paths['test_dir'], \
44                                     f"{os.path.basename(self.paths['exe'])}.stdout"),
45                             'stderr': os.path.join(self.paths['test_dir'], \
46                                     f"{os.path.basename(self.paths['exe'])}.stderr"),
47                             'exitcode': os.path.join(self.paths['test_dir'], \
48                                     f"{os.path.basename(self.paths['exe'])}.exitcode"),
49                             }
50
51     def setup(self, parameters):
52         """Setup instance variables for test."""
53
54         self.parameters = parameters
55         if not os.path.exists(self.paths['test_dir']):
56             os.mkdir(self.paths['test_dir'])
57
58     def run(self):
59         """Run the test."""
60
61         raise NotImplementedError()
62
63     def check_result(self):
64         """Check test results."""
65
66         raise NotImplementedError()
67
68
69 class FioExeTest(FioTest):
70     """Test consists of an executable binary or script"""
71
72     def run(self):
73         """Execute the binary or script described by this instance."""
74
75         command = [self.paths['exe']] + self.parameters
76         with open(self.filenames['cmd'], "w+",
77                   encoding=locale.getpreferredencoding()) as command_file:
78             command_file.write(" \\\n ".join(command))
79
80         try:
81             with open(self.filenames['stdout'], "w+",
82                       encoding=locale.getpreferredencoding()) as stdout_file, \
83                 open(self.filenames['stderr'], "w+",
84                      encoding=locale.getpreferredencoding()) as stderr_file, \
85                 open(self.filenames['exitcode'], "w+",
86                      encoding=locale.getpreferredencoding()) as exitcode_file:
87                 proc = None
88                 # Avoid using subprocess.run() here because when a timeout occurs,
89                 # fio will be stopped with SIGKILL. This does not give fio a
90                 # chance to clean up and means that child processes may continue
91                 # running and submitting IO.
92                 proc = subprocess.Popen(command,
93                                         stdout=stdout_file,
94                                         stderr=stderr_file,
95                                         cwd=self.paths['test_dir'],
96                                         universal_newlines=True)
97                 proc.communicate(timeout=self.success['timeout'])
98                 exitcode_file.write(f'{proc.returncode}\n')
99                 logging.debug("Test %d: return code: %d", self.testnum, proc.returncode)
100                 self.output['proc'] = proc
101         except subprocess.TimeoutExpired:
102             proc.terminate()
103             proc.communicate()
104             assert proc.poll()
105             self.output['failure'] = 'timeout'
106         except Exception:
107             if proc:
108                 if not proc.poll():
109                     proc.terminate()
110                     proc.communicate()
111             self.output['failure'] = 'exception'
112             self.output['exc_info'] = sys.exc_info()
113
114     def check_result(self):
115         """Check results of test run."""
116
117         if 'proc' not in self.output:
118             if self.output['failure'] == 'timeout':
119                 self.failure_reason = f"{self.failure_reason} timeout,"
120             else:
121                 assert self.output['failure'] == 'exception'
122                 self.failure_reason = f'{self.failure_reason} exception: ' + \
123                 f'{self.output["exc_info"][0]}, {self.output["exc_info"][1]}'
124
125             self.passed = False
126             return
127
128         if 'zero_return' in self.success:
129             if self.success['zero_return']:
130                 if self.output['proc'].returncode != 0:
131                     self.passed = False
132                     self.failure_reason = f"{self.failure_reason} non-zero return code,"
133             else:
134                 if self.output['proc'].returncode == 0:
135                     self.failure_reason = f"{self.failure_reason} zero return code,"
136                     self.passed = False
137
138         stderr_size = os.path.getsize(self.filenames['stderr'])
139         if 'stderr_empty' in self.success:
140             if self.success['stderr_empty']:
141                 if stderr_size != 0:
142                     self.failure_reason = f"{self.failure_reason} stderr not empty size {stderr_size},"
143                     self.passed = False
144             else:
145                 if stderr_size == 0:
146                     self.failure_reason = f"{self.failure_reason} stderr empty,"
147                     self.passed = False
148
149
150 class FioJobFileTest(FioExeTest):
151     """Test consists of a fio job with options in a job file."""
152
153     def __init__(self, fio_path, fio_job, success, testnum, artifact_root,
154                  fio_pre_job=None, fio_pre_success=None,
155                  output_format="normal"):
156         """Construct a FioJobFileTest which is a FioExeTest consisting of a
157         single fio job file with an optional setup step.
158
159         fio_path:           location of fio executable
160         fio_job:            location of fio job file
161         success:            Definition of test success
162         testnum:            test ID
163         artifact_root:      root directory for artifacts
164         fio_pre_job:        fio job for preconditioning
165         fio_pre_success:    Definition of test success for fio precon job
166         output_format:      normal (default), json, jsonplus, or terse
167         """
168
169         self.fio_job = fio_job
170         self.fio_pre_job = fio_pre_job
171         self.fio_pre_success = fio_pre_success if fio_pre_success else success
172         self.output_format = output_format
173         self.precon_failed = False
174         self.json_data = None
175
176         super().__init__(fio_path, success, testnum, artifact_root)
177
178     def setup(self, parameters):
179         """Setup instance variables for fio job test."""
180
181         self.filenames['fio_output'] = f"{os.path.basename(self.fio_job)}.output"
182         fio_args = [
183             "--max-jobs=16",
184             f"--output-format={self.output_format}",
185             f"--output={self.filenames['fio_output']}",
186             self.fio_job,
187             ]
188         if parameters:
189             fio_args += parameters
190
191         super().setup(fio_args)
192
193         # Update the filenames from the default
194         self.filenames['cmd'] = os.path.join(self.paths['test_dir'],
195                                              f"{os.path.basename(self.fio_job)}.command")
196         self.filenames['stdout'] = os.path.join(self.paths['test_dir'],
197                                                 f"{os.path.basename(self.fio_job)}.stdout")
198         self.filenames['stderr'] = os.path.join(self.paths['test_dir'],
199                                                 f"{os.path.basename(self.fio_job)}.stderr")
200         self.filenames['exitcode'] = os.path.join(self.paths['test_dir'],
201                                                   f"{os.path.basename(self.fio_job)}.exitcode")
202
203     def run_pre_job(self):
204         """Run fio job precondition step."""
205
206         precon = FioJobFileTest(self.paths['exe'], self.fio_pre_job,
207                             self.fio_pre_success,
208                             self.testnum,
209                             self.paths['artifacts'],
210                             output_format=self.output_format)
211         precon.setup(None)
212         precon.run()
213         precon.check_result()
214         self.precon_failed = not precon.passed
215         self.failure_reason = precon.failure_reason
216
217     def run(self):
218         """Run fio job test."""
219
220         if self.fio_pre_job:
221             self.run_pre_job()
222
223         if not self.precon_failed:
224             super().run()
225         else:
226             logging.debug("Test %d: precondition step failed", self.testnum)
227
228     def get_file_fail(self, filename):
229         """Safely read a file and fail the test upon error."""
230         file_data = None
231
232         try:
233             with open(filename, "r", encoding=locale.getpreferredencoding()) as output_file:
234                 file_data = output_file.read()
235         except OSError:
236             self.failure_reason += f" unable to read file {filename}"
237             self.passed = False
238
239         return file_data
240
241     def check_result(self):
242         """Check fio job results."""
243
244         if self.precon_failed:
245             self.passed = False
246             self.failure_reason = f"{self.failure_reason} precondition step failed,"
247             return
248
249         super().check_result()
250
251         if not self.passed:
252             return
253
254         if 'json' not in self.output_format:
255             return
256
257         file_data = self.get_file_fail(os.path.join(self.paths['test_dir'],
258                                                     self.filenames['fio_output']))
259         if not file_data:
260             return
261
262         #
263         # Sometimes fio informational messages are included outside the JSON
264         # output, especially under Windows. Try to decode output as JSON data,
265         # skipping outside the first { and last }
266         #
267         lines = file_data.splitlines()
268         last = len(lines) - lines[::-1].index("}")
269         file_data = '\n'.join(lines[lines.index("{"):last])
270         try:
271             self.json_data = json.loads(file_data)
272         except json.JSONDecodeError:
273             self.failure_reason = f"{self.failure_reason} unable to decode JSON data,"
274             self.passed = False
275
276
277 class FioJobCmdTest(FioExeTest):
278     """This runs a fio job with options specified on the command line."""
279
280     def __init__(self, fio_path, success, testnum, artifact_root, fio_opts, basename=None):
281
282         self.basename = basename if basename else os.path.basename(fio_path)
283         self.fio_opts = fio_opts
284         self.json_data = None
285         self.iops_log_lines = None
286
287         super().__init__(fio_path, success, testnum, artifact_root)
288
289         filename_stub = os.path.join(self.paths['test_dir'], f"{self.basename}{self.testnum:03d}")
290         self.filenames['cmd'] = f"{filename_stub}.command"
291         self.filenames['stdout'] = f"{filename_stub}.stdout"
292         self.filenames['stderr'] = f"{filename_stub}.stderr"
293         self.filenames['output'] = os.path.abspath(f"{filename_stub}.output")
294         self.filenames['exitcode'] = f"{filename_stub}.exitcode"
295         self.filenames['iopslog'] = os.path.abspath(f"{filename_stub}")
296
297     def run(self):
298         super().run()
299
300         if 'output-format' in self.fio_opts and 'json' in \
301                 self.fio_opts['output-format']:
302             if not self.get_json():
303                 print('Unable to decode JSON data')
304                 self.passed = False
305
306         if any('--write_iops_log=' in param for param in self.parameters):
307             self.get_iops_log()
308
309     def get_iops_log(self):
310         """Read IOPS log from the first job."""
311
312         log_filename = self.filenames['iopslog'] + "_iops.1.log"
313         with open(log_filename, 'r', encoding=locale.getpreferredencoding()) as iops_file:
314             self.iops_log_lines = iops_file.read()
315
316     def get_json(self):
317         """Convert fio JSON output into a python JSON object"""
318
319         filename = self.filenames['output']
320         with open(filename, 'r', encoding=locale.getpreferredencoding()) as file:
321             file_data = file.read()
322
323         #
324         # Sometimes fio informational messages are included outside the JSON
325         # output, especially under Windows. Try to decode output as JSON data,
326         # skipping outside the first { and last }
327         #
328         lines = file_data.splitlines()
329         last = len(lines) - lines[::-1].index("}")
330         file_data = '\n'.join(lines[lines.index("{"):last])
331         try:
332             self.json_data = json.loads(file_data)
333         except json.JSONDecodeError:
334             return False
335
336         return True
337
338     @staticmethod
339     def check_empty(job):
340         """
341         Make sure JSON data is empty.
342
343         Some data structures should be empty. This function makes sure that they are.
344
345         job         JSON object that we need to check for emptiness
346         """
347
348         return job['total_ios'] == 0 and \
349                 job['slat_ns']['N'] == 0 and \
350                 job['clat_ns']['N'] == 0 and \
351                 job['lat_ns']['N'] == 0
352
353     def check_all_ddirs(self, ddir_nonzero, job):
354         """
355         Iterate over the data directions and check whether each is
356         appropriately empty or not.
357         """
358
359         retval = True
360         ddirlist = ['read', 'write', 'trim']
361
362         for ddir in ddirlist:
363             if ddir in ddir_nonzero:
364                 if self.check_empty(job[ddir]):
365                     print(f"Unexpected zero {ddir} data found in output")
366                     retval = False
367             else:
368                 if not self.check_empty(job[ddir]):
369                     print(f"Unexpected {ddir} data found in output")
370                     retval = False
371
372         return retval
373
374
375 def run_fio_tests(test_list, test_env, args):
376     """
377     Run tests as specified in test_list.
378     """
379
380     passed = 0
381     failed = 0
382     skipped = 0
383
384     for config in test_list:
385         if (args.skip and config['test_id'] in args.skip) or \
386            (args.run_only and config['test_id'] not in args.run_only) or \
387            ('force_skip' in config and config['force_skip']):
388             skipped = skipped + 1
389             print(f"Test {config['test_id']} SKIPPED (User request or override)")
390             continue
391
392         if issubclass(config['test_class'], FioJobFileTest):
393             if config['pre_job']:
394                 fio_pre_job = os.path.join(test_env['fio_root'], 't', 'jobs',
395                                            config['pre_job'])
396             else:
397                 fio_pre_job = None
398             if config['pre_success']:
399                 fio_pre_success = config['pre_success']
400             else:
401                 fio_pre_success = None
402             if 'output_format' in config:
403                 output_format = config['output_format']
404             else:
405                 output_format = 'normal'
406             test = config['test_class'](
407                 test_env['fio_path'],
408                 os.path.join(test_env['fio_root'], 't', 'jobs', config['job']),
409                 config['success'],
410                 config['test_id'],
411                 test_env['artifact_root'],
412                 fio_pre_job=fio_pre_job,
413                 fio_pre_success=fio_pre_success,
414                 output_format=output_format)
415             desc = config['job']
416             parameters = config['parameters'] if 'parameters' in config else None
417         elif issubclass(config['test_class'], FioJobCmdTest):
418             if not 'success' in config:
419                 config['success'] = SUCCESS_DEFAULT
420             test = config['test_class'](test_env['fio_path'],
421                                         config['success'],
422                                         config['test_id'],
423                                         test_env['artifact_root'],
424                                         config['fio_opts'],
425                                         test_env['basename'])
426             desc = config['test_id']
427             parameters = config
428         elif issubclass(config['test_class'], FioExeTest):
429             exe_path = os.path.join(test_env['fio_root'], config['exe'])
430             parameters = []
431             if config['parameters']:
432                 parameters = [p.format(fio_path=test_env['fio_path'], nvmecdev=args.nvmecdev)
433                               for p in config['parameters']]
434             if Path(exe_path).suffix == '.py' and platform.system() == "Windows":
435                 parameters.insert(0, exe_path)
436                 exe_path = "python.exe"
437             if config['test_id'] in test_env['pass_through']:
438                 parameters += test_env['pass_through'][config['test_id']].split()
439             test = config['test_class'](
440                     exe_path,
441                     config['success'],
442                     config['test_id'],
443                     test_env['artifact_root'])
444             desc = config['exe']
445         else:
446             print(f"Test {config['test_id']} FAILED: unable to process test config")
447             failed = failed + 1
448             continue
449
450         if 'requirements' in config and not args.skip_req:
451             reqs_met = True
452             for req in config['requirements']:
453                 reqs_met, reason = req()
454                 logging.debug("Test %d: Requirement '%s' met? %s", config['test_id'], reason,
455                               reqs_met)
456                 if not reqs_met:
457                     break
458             if not reqs_met:
459                 print(f"Test {config['test_id']} SKIPPED ({reason}) {desc}")
460                 skipped = skipped + 1
461                 continue
462
463         try:
464             test.setup(parameters)
465             test.run()
466             test.check_result()
467         except KeyboardInterrupt:
468             break
469         except Exception as e:
470             test.passed = False
471             test.failure_reason += str(e)
472             logging.debug("Test %d exception:\n%s\n", config['test_id'], traceback.format_exc())
473         if test.passed:
474             result = "PASSED"
475             passed = passed + 1
476         else:
477             result = f"FAILED: {test.failure_reason}"
478             failed = failed + 1
479             contents, _ = get_file(test.filenames['stderr'])
480             logging.debug("Test %d: stderr:\n%s", config['test_id'], contents)
481             contents, _ = get_file(test.filenames['stdout'])
482             logging.debug("Test %d: stdout:\n%s", config['test_id'], contents)
483         print(f"Test {config['test_id']} {result} {desc}")
484
485     print(f"{passed} test(s) passed, {failed} failed, {skipped} skipped")
486
487     return passed, failed, skipped