53f550759260bff35d2c0dea0d6481152bbef92f
[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('--sysmetrics', help='system metrics logger to use')
165     group = parser.add_argument_group('test behavior options')
166     group.add_argument('--xunit', action='store_true',
167                        help='enable xUnit-formatted output')
168     group.add_argument('--xunit-dir', action=_ValidateDirAction,
169                        help='output directory of xUnit-formatted output')
170     group.add_argument('--load-env', action='store_true',
171                        help='enable loading of settings from the environment')
172     group.add_argument('--conf-file', action=_ValidateFileAction,
173                        help='settings file')
174     group.add_argument('--test-params', action=_SplitTestParamsAction,
175                        help='csv list of test parameters: key=val; e.g.'
176                        'including pkt_sizes=x,y; duration=x; '
177                        'rfc2544_trials=x ...')
178     group.add_argument('--opnfvpod', help='name of POD in opnfv')
179
180     args = vars(parser.parse_args())
181
182     return args
183
184
185 def configure_logging(level):
186     """Configure logging.
187     """
188     log_file_default = os.path.join(
189         settings.getValue('LOG_DIR'), settings.getValue('LOG_FILE_DEFAULT'))
190     log_file_host_cmds = os.path.join(
191         settings.getValue('LOG_DIR'), settings.getValue('LOG_FILE_HOST_CMDS'))
192     log_file_traffic_gen = os.path.join(
193         settings.getValue('LOG_DIR'),
194         settings.getValue('LOG_FILE_TRAFFIC_GEN'))
195
196     logger = logging.getLogger()
197     logger.setLevel(logging.DEBUG)
198
199     stream_logger = logging.StreamHandler(sys.stdout)
200     stream_logger.setLevel(VERBOSITY_LEVELS[level])
201     stream_logger.setFormatter(logging.Formatter(
202         '[%(levelname)s]  %(asctime)s : (%(name)s) - %(message)s'))
203     logger.addHandler(stream_logger)
204
205     file_logger = logging.FileHandler(filename=log_file_default)
206     file_logger.setLevel(logging.DEBUG)
207     logger.addHandler(file_logger)
208
209     class CommandFilter(logging.Filter):
210         """Filter out strings beginning with 'cmd :'"""
211         def filter(self, record):
212             return record.getMessage().startswith(tasks.CMD_PREFIX)
213
214     class TrafficGenCommandFilter(logging.Filter):
215         """Filter out strings beginning with 'gencmd :'"""
216         def filter(self, record):
217             return record.getMessage().startswith(trafficgen.CMD_PREFIX)
218
219     cmd_logger = logging.FileHandler(filename=log_file_host_cmds)
220     cmd_logger.setLevel(logging.DEBUG)
221     cmd_logger.addFilter(CommandFilter())
222     logger.addHandler(cmd_logger)
223
224     gen_logger = logging.FileHandler(filename=log_file_traffic_gen)
225     gen_logger.setLevel(logging.DEBUG)
226     gen_logger.addFilter(TrafficGenCommandFilter())
227     logger.addHandler(gen_logger)
228
229
230 def apply_filter(tests, tc_filter):
231     """Allow a subset of tests to be conveniently selected
232
233     :param tests: The list of Tests from which to select.
234     :param tc_filter: A case-insensitive string of comma-separated terms
235         indicating the Tests to select.
236         e.g. 'RFC' - select all tests whose name contains 'RFC'
237         e.g. 'RFC,burst' - select all tests whose name contains 'RFC' or
238             'burst'
239         e.g. 'RFC,burst,!p2p' - select all tests whose name contains 'RFC'
240             or 'burst' and from these remove any containing 'p2p'.
241         e.g. '' - empty string selects all tests.
242     :return: A list of the selected Tests.
243     """
244     result = []
245     if tc_filter is None:
246         tc_filter = ""
247
248     for term in [x.strip() for x in tc_filter.lower().split(",")]:
249         if not term or term[0] != '!':
250             # Add matching tests from 'tests' into results
251             result.extend([test for test in tests \
252                 if test.name.lower().find(term) >= 0])
253         else:
254             # Term begins with '!' so we remove matching tests
255             result = [test for test in result \
256                 if test.name.lower().find(term[1:]) < 0]
257
258     return result
259
260
261 def check_and_set_locale():
262     """ Function will check locale settings. In case, that it isn't configured
263     properly, then default values specified by DEFAULT_LOCALE will be used.
264     """
265
266     system_locale = locale.getdefaultlocale()
267     if None in system_locale:
268         os.environ['LC_ALL'] = settings.getValue('DEFAULT_LOCALE')
269         logging.warning("Locale was not properly configured. Default values were set. Old locale: %s, New locale: %s",
270                         system_locale, locale.getdefaultlocale())
271
272
273 def generate_final_report(path):
274     """ Function will check if partial test results are available
275     and generates final report in rst format.
276     """
277
278     # check if there are any results in rst format
279     rst_results = glob.glob(os.path.join(path, 'result*rst'))
280     if len(rst_results):
281         try:
282             test_report = os.path.join(path, '{}_{}'.format(settings.getValue('VSWITCH'), _TEMPLATE_RST['final']))
283             # create report caption directly - it is not worth to execute jinja machinery
284             report_caption = '{}\n{} {}\n{}\n\n'.format(
285                 '============================================================',
286                 'Performance report for',
287                 Loader().get_vswitches()[settings.getValue('VSWITCH')].__doc__.strip().split('\n')[0],
288
289                 '============================================================')
290
291             with open(_TEMPLATE_RST['tmp'], 'w') as file_:
292                 file_.write(report_caption)
293
294             retval = subprocess.call('cat {} {} {} {} > {}'.format(_TEMPLATE_RST['tmp'], _TEMPLATE_RST['head'],
295                                                                    ' '.join(rst_results), _TEMPLATE_RST['foot'],
296                                                                    test_report), shell=True)
297             if retval == 0 and os.path.isfile(test_report):
298                 logging.info('Overall test report written to "%s"', test_report)
299             else:
300                 logging.error('Generatrion of overall test report has failed.')
301
302             # remove temporary file
303             os.remove(_TEMPLATE_RST['tmp'])
304
305         except subprocess.CalledProcessError:
306             logging.error('Generatrion of overall test report has failed.')
307
308
309 class MockTestCase(unittest.TestCase):
310     """Allow use of xmlrunner to generate Jenkins compatible output without
311     using xmlrunner to actually run tests.
312
313     Usage:
314         suite = unittest.TestSuite()
315         suite.addTest(MockTestCase('Test1 passed ', True, 'Test1'))
316         suite.addTest(MockTestCase('Test2 failed because...', False, 'Test2'))
317         xmlrunner.XMLTestRunner(...).run(suite)
318     """
319
320     def __init__(self, msg, is_pass, test_name):
321         #remember the things
322         self.msg = msg
323         self.is_pass = is_pass
324
325         #dynamically create a test method with the right name
326         #but point the method at our generic test method
327         setattr(MockTestCase, test_name, self.generic_test)
328
329         super(MockTestCase, self).__init__(test_name)
330
331     def generic_test(self):
332         """Provide a generic function that raises or not based
333         on how self.is_pass was set in the constructor"""
334         self.assertTrue(self.is_pass, self.msg)
335
336
337 def main():
338     """Main function.
339     """
340     args = parse_arguments()
341
342     # configure settings
343
344     settings.load_from_dir('conf')
345
346     performance_test = True
347
348     # Load non performance/integration tests
349     if args['run_integration']:
350         performance_test = False
351         settings.load_from_dir('conf/integration')
352
353     # load command line parameters first in case there are settings files
354     # to be used
355     settings.load_from_dict(args)
356
357     if args['conf_file']:
358         settings.load_from_file(args['conf_file'])
359
360     if args['load_env']:
361         settings.load_from_env()
362
363     # reload command line parameters since these should take higher priority
364     # than both a settings file and environment variables
365     settings.load_from_dict(args)
366
367     vswitch_none = False
368     # set dpdk and ovs paths accorfing to VNF and VSWITCH
369     if settings.getValue('VSWITCH').endswith('Vanilla'):
370         # settings paths for Vanilla
371         settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_VANILLA')))
372     elif settings.getValue('VSWITCH').endswith('Vhost'):
373         if settings.getValue('VNF').endswith('Cuse'):
374             # settings paths for Cuse
375             settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_CUSE')))
376             settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_CUSE')))
377         else:
378             # settings paths for VhostUser
379             settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_USER')))
380             settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_USER')))
381     else:
382         # default - set to VHOST USER but can be changed during enhancement
383         settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_USER')))
384         settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_USER')))
385         if 'none' == settings.getValue('VSWITCH').strip().lower():
386             vswitch_none = True
387
388     configure_logging(settings.getValue('VERBOSITY'))
389     logger = logging.getLogger()
390
391     # check and fix locale
392     check_and_set_locale()
393
394     # configure trafficgens
395     if args['trafficgen']:
396         trafficgens = Loader().get_trafficgens()
397         if args['trafficgen'] not in trafficgens:
398             logging.error('There are no trafficgens matching \'%s\' found in'
399                           ' \'%s\'. Exiting...', args['trafficgen'],
400                           settings.getValue('TRAFFICGEN_DIR'))
401             sys.exit(1)
402
403     # configure vswitch
404     if args['vswitch']:
405         vswitch_none = 'none' == args['vswitch'].strip().lower()
406         if vswitch_none:
407             settings.setValue('VSWITCH', 'none')
408         else:
409             vswitches = Loader().get_vswitches()
410             if args['vswitch'] not in vswitches:
411                 logging.error('There are no vswitches matching \'%s\' found in'
412                               ' \'%s\'. Exiting...', args['vswitch'],
413                               settings.getValue('VSWITCH_DIR'))
414                 sys.exit(1)
415
416     if args['fwdapp']:
417         settings.setValue('PKTFWD', args['fwdapp'])
418         fwdapps = Loader().get_pktfwds()
419         if args['fwdapp'] not in fwdapps:
420             logging.error('There are no forwarding application'
421                           ' matching \'%s\' found in'
422                           ' \'%s\'. Exiting...', args['fwdapp'],
423                           settings.getValue('PKTFWD_DIR'))
424             sys.exit(1)
425
426     if args['vnf']:
427         vnfs = Loader().get_vnfs()
428         if args['vnf'] not in vnfs:
429             logging.error('there are no vnfs matching \'%s\' found in'
430                           ' \'%s\'. exiting...', args['vnf'],
431                           settings.getValue('vnf_dir'))
432             sys.exit(1)
433
434     # update global settings
435     guest_loopback = get_test_param('guest_loopback', None)
436     if guest_loopback:
437         tmp_gl = []
438         for i in range(len(settings.getValue('GUEST_LOOPBACK'))):
439             tmp_gl.append(guest_loopback)
440         settings.setValue('GUEST_LOOPBACK', tmp_gl)
441
442     settings.setValue('mode', args['mode'])
443
444     # generate results directory name
445     date = datetime.datetime.fromtimestamp(time.time())
446     results_dir = "results_" + date.strftime('%Y-%m-%d_%H-%M-%S')
447     results_path = os.path.join(settings.getValue('LOG_DIR'), results_dir)
448
449     # create results directory
450     if not os.path.exists(results_path):
451         logger.info("Creating result directory: "  + results_path)
452         os.makedirs(results_path)
453
454     if settings.getValue('mode') == 'trafficgen':
455         # execute only traffic generator
456         logging.debug("Executing traffic generator:")
457         loader = Loader()
458         # set traffic details, so they can be passed to traffic ctl
459         traffic = copy.deepcopy(TRAFFIC_DEFAULTS)
460         traffic.update({'traffic_type': get_test_param('traffic_type', 'rfc2544'),
461                         'bidir': get_test_param('bidirectional', False),
462                         'multistream': int(get_test_param('multistream', 0)),
463                         'stream_type': get_test_param('stream_type', 'L4'),
464                         'frame_rate': int(get_test_param('iload', 100))})
465
466         traffic_ctl = component_factory.create_traffic(
467             traffic['traffic_type'],
468             loader.get_trafficgen_class())
469         with traffic_ctl:
470             traffic_ctl.send_traffic(traffic)
471         logging.debug("Traffic Results:")
472         traffic_ctl.print_results()
473     else:
474         # configure tests
475         testcases = settings.getValue('PERFORMANCE_TESTS')
476         if args['run_integration']:
477             testcases = settings.getValue('INTEGRATION_TESTS')
478
479         all_tests = []
480         for cfg in testcases:
481             try:
482                 all_tests.append(TestCase(cfg, results_path, performance_test))
483             except (Exception) as _:
484                 logger.exception("Failed to create test: %s",
485                                  cfg.get('Name', '<Name not set>'))
486                 raise
487
488         # if required, handle list-* operations
489
490         if args['list']:
491             print("Available Tests:")
492             print("======")
493             for test in all_tests:
494                 print('* %-18s%s' % ('%s:' % test.name, test.desc))
495             exit()
496
497         if args['list_trafficgens']:
498             print(Loader().get_trafficgens_printable())
499             exit()
500
501         if args['list_collectors']:
502             print(Loader().get_collectors_printable())
503             exit()
504
505         if args['list_vswitches']:
506             print(Loader().get_vswitches_printable())
507             exit()
508
509         if args['list_vnfs']:
510             print(Loader().get_vnfs_printable())
511             exit()
512
513         if args['list_settings']:
514             print(str(settings))
515             exit()
516
517         # select requested tests
518         if args['exact_test_name'] and args['tests']:
519             logger.error("Cannot specify tests with both positional args and --test.")
520             sys.exit(1)
521
522         if args['exact_test_name']:
523             exact_names = args['exact_test_name']
524             # positional args => exact matches only
525             selected_tests = [test for test in all_tests if test.name in exact_names]
526         elif args['tests']:
527             # --tests => apply filter to select requested tests
528             selected_tests = apply_filter(all_tests, args['tests'])
529         else:
530             # Default - run all tests
531             selected_tests = all_tests
532
533         if not selected_tests:
534             logger.error("No tests matched --test option or positional args. Done.")
535             sys.exit(1)
536
537         # run tests
538         suite = unittest.TestSuite()
539         for test in selected_tests:
540             try:
541                 test.run()
542                 suite.addTest(MockTestCase('', True, test.name))
543             #pylint: disable=broad-except
544             except (Exception) as ex:
545                 logger.exception("Failed to run test: %s", test.name)
546                 suite.addTest(MockTestCase(str(ex), False, test.name))
547                 logger.info("Continuing with next test...")
548
549         # generate final rst report with results of all executed TCs
550         generate_final_report(results_path)
551
552         if settings.getValue('XUNIT'):
553             xmlrunner.XMLTestRunner(
554                 output=settings.getValue('XUNIT_DIR'), outsuffix="",
555                 verbosity=0).run(suite)
556
557         if args['opnfvpod']:
558             pod_name = args['opnfvpod']
559             installer_name = settings.getValue('OPNFV_INSTALLER')
560             opnfv_url = settings.getValue('OPNFV_URL')
561             pkg_list = settings.getValue('PACKAGE_LIST')
562
563             int_data = {'cuse': False,
564                         'vanilla': False,
565                         'pod': pod_name,
566                         'installer': installer_name,
567                         'pkg_list': pkg_list,
568                         'db_url': opnfv_url}
569             if settings.getValue('VSWITCH').endswith('Vanilla'):
570                 int_data['vanilla'] = True
571             if settings.getValue('VNF').endswith('Cuse'):
572                 int_data['cuse'] = True
573             opnfvdashboard.results2opnfv_dashboard(results_path, int_data)
574
575     #remove directory if no result files were created.
576     if os.path.exists(results_path):
577         files_list = os.listdir(results_path)
578         if files_list == []:
579             shutil.rmtree(results_path)
580
581 if __name__ == "__main__":
582     main()
583
584