db1d5ee55ba31bcd70de9597df3d89c5072e92a9
[vswitchperf.git] / vsperf
1 #!/usr/bin/env python3
2
3 # Copyright 2015-2016 Intel Corporation.
4 #
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
8 #
9 #   http://www.apache.org/licenses/LICENSE-2.0
10 #
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
16
17 """VSPERF main script.
18 """
19
20 import logging
21 import os
22 import sys
23 import argparse
24 import time
25 import datetime
26 import shutil
27 import unittest
28 import xmlrunner
29 import locale
30 import copy
31 import glob
32 import subprocess
33
34 sys.dont_write_bytecode = True
35
36 from conf import settings
37 from conf import get_test_param
38 from core.loader import Loader
39 from testcases import TestCase
40 from tools import tasks
41 from tools.pkt_gen import trafficgen
42 from tools.opnfvdashboard import opnfvdashboard
43 from tools.pkt_gen.trafficgen.trafficgenhelper import TRAFFIC_DEFAULTS
44 import core.component_factory as component_factory
45
46 VERBOSITY_LEVELS = {
47     'debug': logging.DEBUG,
48     'info': logging.INFO,
49     'warning': logging.WARNING,
50     'error': logging.ERROR,
51     'critical': logging.CRITICAL
52 }
53
54 _TEMPLATE_RST = {'head'  : 'tools/report/report_head.rst',
55                  'foot'  : 'tools/report/report_foot.rst',
56                  'final' : 'test_report.rst',
57                  'tmp'   : 'tools/report/report_tmp_caption.rst'
58                 }
59
60 def parse_arguments():
61     """
62     Parse command line arguments.
63     """
64     class _SplitTestParamsAction(argparse.Action):
65         """
66         Parse and split the '--test-params' argument.
67
68         This expects either 'x=y', 'x=y,z' or 'x' (implicit true)
69         values. For multiple overrides use a ; separated list for
70         e.g. --test-params 'x=z; y=a,b'
71         """
72         def __call__(self, parser, namespace, values, option_string=None):
73             results = {}
74
75             for value in values.split(';'):
76                 result = [key.strip() for key in value.split('=')]
77                 if len(result) == 1:
78                     results[result[0]] = True
79                 elif len(result) == 2:
80                     results[result[0]] = result[1]
81                 else:
82                     raise argparse.ArgumentTypeError(
83                         'expected \'%s\' to be of format \'key=val\' or'
84                         ' \'key\'' % result)
85
86             setattr(namespace, self.dest, results)
87
88     class _ValidateFileAction(argparse.Action):
89         """Validate a file can be read from before using it.
90         """
91         def __call__(self, parser, namespace, values, option_string=None):
92             if not os.path.isfile(values):
93                 raise argparse.ArgumentTypeError(
94                     'the path \'%s\' is not a valid path' % values)
95             elif not os.access(values, os.R_OK):
96                 raise argparse.ArgumentTypeError(
97                     'the path \'%s\' is not accessible' % values)
98
99             setattr(namespace, self.dest, values)
100
101     class _ValidateDirAction(argparse.Action):
102         """Validate a directory can be written to before using it.
103         """
104         def __call__(self, parser, namespace, values, option_string=None):
105             if not os.path.isdir(values):
106                 raise argparse.ArgumentTypeError(
107                     'the path \'%s\' is not a valid path' % values)
108             elif not os.access(values, os.W_OK):
109                 raise argparse.ArgumentTypeError(
110                     'the path \'%s\' is not accessible' % values)
111
112             setattr(namespace, self.dest, values)
113
114     def list_logging_levels():
115         """Give a summary of all available logging levels.
116
117         :return: List of verbosity level names in decreasing order of
118             verbosity
119         """
120         return sorted(VERBOSITY_LEVELS.keys(),
121                       key=lambda x: VERBOSITY_LEVELS[x])
122
123     parser = argparse.ArgumentParser(prog=__file__, formatter_class=
124                                      argparse.ArgumentDefaultsHelpFormatter)
125     parser.add_argument('--version', action='version', version='%(prog)s 0.2')
126     parser.add_argument('--list', '--list-tests', action='store_true',
127                         help='list all tests and exit')
128     parser.add_argument('--list-trafficgens', action='store_true',
129                         help='list all traffic generators and exit')
130     parser.add_argument('--list-collectors', action='store_true',
131                         help='list all system metrics loggers and exit')
132     parser.add_argument('--list-vswitches', action='store_true',
133                         help='list all system vswitches and exit')
134     parser.add_argument('--list-fwdapps', action='store_true',
135                         help='list all system forwarding applications and exit')
136     parser.add_argument('--list-vnfs', action='store_true',
137                         help='list all system vnfs and exit')
138     parser.add_argument('--list-settings', action='store_true',
139                         help='list effective settings configuration and exit')
140     parser.add_argument('exact_test_name', nargs='*', help='Exact names of\
141             tests to run. E.g "vsperf phy2phy_tput phy2phy_cont"\
142             runs only the two tests with those exact names.\
143             To run all tests omit both positional args and --tests arg.')
144
145     group = parser.add_argument_group('test selection options')
146     group.add_argument('-m', '--mode', help='vsperf mode of operation;\
147             Values: "normal" - execute vSwitch, VNF and traffic generator;\
148             "trafficgen" - execute only traffic generator; "trafficgen-off" \
149             - execute vSwitch and VNF; trafficgen-pause - execute vSwitch \
150             and VNF but pause before traffic transmission ', default='normal')
151
152     group.add_argument('-f', '--test-spec', help='test specification file')
153     group.add_argument('-d', '--test-dir', help='directory containing tests')
154     group.add_argument('-t', '--tests', help='Comma-separated list of terms \
155             indicating tests to run. e.g. "RFC2544,!p2p" - run all tests whose\
156             name contains RFC2544 less those containing "p2p"')
157     group.add_argument('--verbosity', choices=list_logging_levels(),
158                        help='debug level')
159     group.add_argument('--run-integration', action='store_true', help='run integration tests')
160     group.add_argument('--trafficgen', help='traffic generator to use')
161     group.add_argument('--vswitch', help='vswitch implementation to use')
162     group.add_argument('--fwdapp', help='packet forwarding application to use')
163     group.add_argument('--vnf', help='vnf to use')
164     group.add_argument('--duration', help='traffic transmit duration')
165     group.add_argument('--sysmetrics', help='system metrics logger to use')
166     group = parser.add_argument_group('test behavior options')
167     group.add_argument('--xunit', action='store_true',
168                        help='enable xUnit-formatted output')
169     group.add_argument('--xunit-dir', action=_ValidateDirAction,
170                        help='output directory of xUnit-formatted output')
171     group.add_argument('--load-env', action='store_true',
172                        help='enable loading of settings from the environment')
173     group.add_argument('--conf-file', action=_ValidateFileAction,
174                        help='settings file')
175     group.add_argument('--test-params', action=_SplitTestParamsAction,
176                        help='csv list of test parameters: key=val; e.g.'
177                        'including pkt_sizes=x,y; duration=x; '
178                        'rfc2544_trials=x ...')
179     group.add_argument('--opnfvpod', help='name of POD in opnfv')
180
181     args = vars(parser.parse_args())
182
183     return args
184
185
186 def configure_logging(level):
187     """Configure logging.
188     """
189     log_file_default = os.path.join(
190         settings.getValue('LOG_DIR'), settings.getValue('LOG_FILE_DEFAULT'))
191     log_file_host_cmds = os.path.join(
192         settings.getValue('LOG_DIR'), settings.getValue('LOG_FILE_HOST_CMDS'))
193     log_file_traffic_gen = os.path.join(
194         settings.getValue('LOG_DIR'),
195         settings.getValue('LOG_FILE_TRAFFIC_GEN'))
196
197     logger = logging.getLogger()
198     logger.setLevel(logging.DEBUG)
199
200     stream_logger = logging.StreamHandler(sys.stdout)
201     stream_logger.setLevel(VERBOSITY_LEVELS[level])
202     stream_logger.setFormatter(logging.Formatter(
203         '[%(levelname)s]  %(asctime)s : (%(name)s) - %(message)s'))
204     logger.addHandler(stream_logger)
205
206     file_logger = logging.FileHandler(filename=log_file_default)
207     file_logger.setLevel(logging.DEBUG)
208     logger.addHandler(file_logger)
209
210     class CommandFilter(logging.Filter):
211         """Filter out strings beginning with 'cmd :'"""
212         def filter(self, record):
213             return record.getMessage().startswith(tasks.CMD_PREFIX)
214
215     class TrafficGenCommandFilter(logging.Filter):
216         """Filter out strings beginning with 'gencmd :'"""
217         def filter(self, record):
218             return record.getMessage().startswith(trafficgen.CMD_PREFIX)
219
220     cmd_logger = logging.FileHandler(filename=log_file_host_cmds)
221     cmd_logger.setLevel(logging.DEBUG)
222     cmd_logger.addFilter(CommandFilter())
223     logger.addHandler(cmd_logger)
224
225     gen_logger = logging.FileHandler(filename=log_file_traffic_gen)
226     gen_logger.setLevel(logging.DEBUG)
227     gen_logger.addFilter(TrafficGenCommandFilter())
228     logger.addHandler(gen_logger)
229
230
231 def apply_filter(tests, tc_filter):
232     """Allow a subset of tests to be conveniently selected
233
234     :param tests: The list of Tests from which to select.
235     :param tc_filter: A case-insensitive string of comma-separated terms
236         indicating the Tests to select.
237         e.g. 'RFC' - select all tests whose name contains 'RFC'
238         e.g. 'RFC,burst' - select all tests whose name contains 'RFC' or
239             'burst'
240         e.g. 'RFC,burst,!p2p' - select all tests whose name contains 'RFC'
241             or 'burst' and from these remove any containing 'p2p'.
242         e.g. '' - empty string selects all tests.
243     :return: A list of the selected Tests.
244     """
245     result = []
246     if tc_filter is None:
247         tc_filter = ""
248
249     for term in [x.strip() for x in tc_filter.lower().split(",")]:
250         if not term or term[0] != '!':
251             # Add matching tests from 'tests' into results
252             result.extend([test for test in tests \
253                 if test.name.lower().find(term) >= 0])
254         else:
255             # Term begins with '!' so we remove matching tests
256             result = [test for test in result \
257                 if test.name.lower().find(term[1:]) < 0]
258
259     return result
260
261
262 def check_and_set_locale():
263     """ Function will check locale settings. In case, that it isn't configured
264     properly, then default values specified by DEFAULT_LOCALE will be used.
265     """
266
267     system_locale = locale.getdefaultlocale()
268     if None in system_locale:
269         os.environ['LC_ALL'] = settings.getValue('DEFAULT_LOCALE')
270         logging.warning("Locale was not properly configured. Default values were set. Old locale: %s, New locale: %s",
271                         system_locale, locale.getdefaultlocale())
272
273
274 def generate_final_report(path):
275     """ Function will check if partial test results are available
276     and generates final report in rst format.
277     """
278
279     # check if there are any results in rst format
280     rst_results = glob.glob(os.path.join(path, 'result*rst'))
281     if len(rst_results):
282         try:
283             test_report = os.path.join(path, '{}_{}'.format(settings.getValue('VSWITCH'), _TEMPLATE_RST['final']))
284             # create report caption directly - it is not worth to execute jinja machinery
285             report_caption = '{}\n{} {}\n{}\n\n'.format(
286                 '============================================================',
287                 'Performance report for',
288                 Loader().get_vswitches()[settings.getValue('VSWITCH')].__doc__.strip().split('\n')[0],
289
290                 '============================================================')
291
292             with open(_TEMPLATE_RST['tmp'], 'w') as file_:
293                 file_.write(report_caption)
294
295             retval = subprocess.call('cat {} {} {} {} > {}'.format(_TEMPLATE_RST['tmp'], _TEMPLATE_RST['head'],
296                                                                    ' '.join(rst_results), _TEMPLATE_RST['foot'],
297                                                                    test_report), shell=True)
298             if retval == 0 and os.path.isfile(test_report):
299                 logging.info('Overall test report written to "%s"', test_report)
300             else:
301                 logging.error('Generatrion of overall test report has failed.')
302
303             # remove temporary file
304             os.remove(_TEMPLATE_RST['tmp'])
305
306         except subprocess.CalledProcessError:
307             logging.error('Generatrion of overall test report has failed.')
308
309
310 class MockTestCase(unittest.TestCase):
311     """Allow use of xmlrunner to generate Jenkins compatible output without
312     using xmlrunner to actually run tests.
313
314     Usage:
315         suite = unittest.TestSuite()
316         suite.addTest(MockTestCase('Test1 passed ', True, 'Test1'))
317         suite.addTest(MockTestCase('Test2 failed because...', False, 'Test2'))
318         xmlrunner.XMLTestRunner(...).run(suite)
319     """
320
321     def __init__(self, msg, is_pass, test_name):
322         #remember the things
323         self.msg = msg
324         self.is_pass = is_pass
325
326         #dynamically create a test method with the right name
327         #but point the method at our generic test method
328         setattr(MockTestCase, test_name, self.generic_test)
329
330         super(MockTestCase, self).__init__(test_name)
331
332     def generic_test(self):
333         """Provide a generic function that raises or not based
334         on how self.is_pass was set in the constructor"""
335         self.assertTrue(self.is_pass, self.msg)
336
337
338 def main():
339     """Main function.
340     """
341     args = parse_arguments()
342
343     # configure settings
344
345     settings.load_from_dir('conf')
346
347     performance_test = True
348
349     # Load non performance/integration tests
350     if args['run_integration']:
351         performance_test = False
352         settings.load_from_dir('conf/integration')
353
354     # load command line parameters first in case there are settings files
355     # to be used
356     settings.load_from_dict(args)
357
358     if args['conf_file']:
359         settings.load_from_file(args['conf_file'])
360
361     if args['load_env']:
362         settings.load_from_env()
363
364     # reload command line parameters since these should take higher priority
365     # than both a settings file and environment variables
366     settings.load_from_dict(args)
367
368     vswitch_none = False
369     # set dpdk and ovs paths accorfing to VNF and VSWITCH
370     if settings.getValue('VSWITCH').endswith('Vanilla'):
371         # settings paths for Vanilla
372         settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_VANILLA')))
373     elif settings.getValue('VSWITCH').endswith('Vhost'):
374         if settings.getValue('VNF').endswith('Cuse'):
375             # settings paths for Cuse
376             settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_CUSE')))
377             settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_CUSE')))
378         else:
379             # settings paths for VhostUser
380             settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_USER')))
381             settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_USER')))
382     else:
383         # default - set to VHOST USER but can be changed during enhancement
384         settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_USER')))
385         settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_USER')))
386         if 'none' == settings.getValue('VSWITCH').strip().lower():
387             vswitch_none = True
388
389     configure_logging(settings.getValue('VERBOSITY'))
390     logger = logging.getLogger()
391
392     # check and fix locale
393     check_and_set_locale()
394
395     # configure trafficgens
396     if args['trafficgen']:
397         trafficgens = Loader().get_trafficgens()
398         if args['trafficgen'] not in trafficgens:
399             logging.error('There are no trafficgens matching \'%s\' found in'
400                           ' \'%s\'. Exiting...', args['trafficgen'],
401                           settings.getValue('TRAFFICGEN_DIR'))
402             sys.exit(1)
403
404     # configure vswitch
405     if args['vswitch']:
406         vswitch_none = 'none' == args['vswitch'].strip().lower()
407         if vswitch_none:
408             settings.setValue('VSWITCH', 'none')
409         else:
410             vswitches = Loader().get_vswitches()
411             if args['vswitch'] not in vswitches:
412                 logging.error('There are no vswitches matching \'%s\' found in'
413                               ' \'%s\'. Exiting...', args['vswitch'],
414                               settings.getValue('VSWITCH_DIR'))
415                 sys.exit(1)
416
417     if args['fwdapp']:
418         settings.setValue('PKTFWD', args['fwdapp'])
419         fwdapps = Loader().get_pktfwds()
420         if args['fwdapp'] not in fwdapps:
421             logging.error('There are no forwarding application'
422                           ' matching \'%s\' found in'
423                           ' \'%s\'. Exiting...', args['fwdapp'],
424                           settings.getValue('PKTFWD_DIR'))
425             sys.exit(1)
426
427     if args['vnf']:
428         vnfs = Loader().get_vnfs()
429         if args['vnf'] not in vnfs:
430             logging.error('there are no vnfs matching \'%s\' found in'
431                           ' \'%s\'. exiting...', args['vnf'],
432                           settings.getValue('vnf_dir'))
433             sys.exit(1)
434
435     if args['duration']:
436         if args['duration'].isdigit() and int(args['duration']) > 0:
437             settings.setValue('duration', args['duration'])
438         else:
439             logging.error('The selected Duration is not a number')
440             sys.exit(1)
441
442     # update global settings
443     guest_loopback = get_test_param('guest_loopback', None)
444     if guest_loopback:
445         tmp_gl = []
446         for i in range(len(settings.getValue('GUEST_LOOPBACK'))):
447             tmp_gl.append(guest_loopback)
448         settings.setValue('GUEST_LOOPBACK', tmp_gl)
449
450     settings.setValue('mode', args['mode'])
451
452     # generate results directory name
453     date = datetime.datetime.fromtimestamp(time.time())
454     results_dir = "results_" + date.strftime('%Y-%m-%d_%H-%M-%S')
455     results_path = os.path.join(settings.getValue('LOG_DIR'), results_dir)
456
457     # create results directory
458     if not os.path.exists(results_path):
459         logger.info("Creating result directory: "  + results_path)
460         os.makedirs(results_path)
461
462     if settings.getValue('mode') == 'trafficgen':
463         # execute only traffic generator
464         logging.debug("Executing traffic generator:")
465         loader = Loader()
466         # set traffic details, so they can be passed to traffic ctl
467         traffic = copy.deepcopy(TRAFFIC_DEFAULTS)
468         traffic.update({'traffic_type': get_test_param('traffic_type', 'rfc2544'),
469                         'bidir': get_test_param('bidirectional', False),
470                         'multistream': int(get_test_param('multistream', 0)),
471                         'stream_type': get_test_param('stream_type', 'L4'),
472                         'frame_rate': int(get_test_param('iload', 100))})
473
474         traffic_ctl = component_factory.create_traffic(
475             traffic['traffic_type'],
476             loader.get_trafficgen_class())
477         with traffic_ctl:
478             traffic_ctl.send_traffic(traffic)
479         logging.debug("Traffic Results:")
480         traffic_ctl.print_results()
481     else:
482         # configure tests
483         testcases = settings.getValue('PERFORMANCE_TESTS')
484         if args['run_integration']:
485             testcases = settings.getValue('INTEGRATION_TESTS')
486
487         all_tests = []
488         for cfg in testcases:
489             try:
490                 all_tests.append(TestCase(cfg, results_path, performance_test))
491             except (Exception) as _:
492                 logger.exception("Failed to create test: %s",
493                                  cfg.get('Name', '<Name not set>'))
494                 raise
495
496         # if required, handle list-* operations
497
498         if args['list']:
499             print("Available Tests:")
500             print("======")
501             for test in all_tests:
502                 print('* %-18s%s' % ('%s:' % test.name, test.desc))
503             exit()
504
505         if args['list_trafficgens']:
506             print(Loader().get_trafficgens_printable())
507             exit()
508
509         if args['list_collectors']:
510             print(Loader().get_collectors_printable())
511             exit()
512
513         if args['list_vswitches']:
514             print(Loader().get_vswitches_printable())
515             exit()
516
517         if args['list_vnfs']:
518             print(Loader().get_vnfs_printable())
519             exit()
520
521         if args['list_settings']:
522             print(str(settings))
523             exit()
524
525         # select requested tests
526         if args['exact_test_name'] and args['tests']:
527             logger.error("Cannot specify tests with both positional args and --test.")
528             sys.exit(1)
529
530         if args['exact_test_name']:
531             exact_names = args['exact_test_name']
532             # positional args => exact matches only
533             selected_tests = [test for test in all_tests if test.name in exact_names]
534         elif args['tests']:
535             # --tests => apply filter to select requested tests
536             selected_tests = apply_filter(all_tests, args['tests'])
537         else:
538             # Default - run all tests
539             selected_tests = all_tests
540
541         if not selected_tests:
542             logger.error("No tests matched --test option or positional args. Done.")
543             sys.exit(1)
544
545         # run tests
546         suite = unittest.TestSuite()
547         for test in selected_tests:
548             try:
549                 test.run()
550                 suite.addTest(MockTestCase('', True, test.name))
551             #pylint: disable=broad-except
552             except (Exception) as ex:
553                 logger.exception("Failed to run test: %s", test.name)
554                 suite.addTest(MockTestCase(str(ex), False, test.name))
555                 logger.info("Continuing with next test...")
556
557         # generate final rst report with results of all executed TCs
558         generate_final_report(results_path)
559
560         if settings.getValue('XUNIT'):
561             xmlrunner.XMLTestRunner(
562                 output=settings.getValue('XUNIT_DIR'), outsuffix="",
563                 verbosity=0).run(suite)
564
565         if args['opnfvpod']:
566             pod_name = args['opnfvpod']
567             installer_name = settings.getValue('OPNFV_INSTALLER')
568             opnfv_url = settings.getValue('OPNFV_URL')
569             pkg_list = settings.getValue('PACKAGE_LIST')
570
571             int_data = {'cuse': False,
572                         'vanilla': False,
573                         'pod': pod_name,
574                         'installer': installer_name,
575                         'pkg_list': pkg_list,
576                         'db_url': opnfv_url}
577             if settings.getValue('VSWITCH').endswith('Vanilla'):
578                 int_data['vanilla'] = True
579             if settings.getValue('VNF').endswith('Cuse'):
580                 int_data['cuse'] = True
581             opnfvdashboard.results2opnfv_dashboard(results_path, int_data)
582
583     #remove directory if no result files were created.
584     if os.path.exists(results_path):
585         files_list = os.listdir(results_path)
586         if files_list == []:
587             shutil.rmtree(results_path)
588
589 if __name__ == "__main__":
590     main()
591
592