xena: Throughput method implementation for Xena Networks
[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 PerformanceTestCase
40 from testcases import IntegrationTestCase
41 from tools import tasks
42 from tools.pkt_gen import trafficgen
43 from tools.opnfvdashboard import opnfvdashboard
44 from tools.pkt_gen.trafficgen.trafficgenhelper import TRAFFIC_DEFAULTS
45 import core.component_factory as component_factory
46
47 VERBOSITY_LEVELS = {
48     'debug': logging.DEBUG,
49     'info': logging.INFO,
50     'warning': logging.WARNING,
51     'error': logging.ERROR,
52     'critical': logging.CRITICAL
53 }
54
55 _TEMPLATE_RST = {'head'  : 'tools/report/report_head.rst',
56                  'foot'  : 'tools/report/report_foot.rst',
57                  'final' : 'test_report.rst',
58                  'tmp'   : 'tools/report/report_tmp_caption.rst'
59                 }
60
61 def parse_arguments():
62     """
63     Parse command line arguments.
64     """
65     class _SplitTestParamsAction(argparse.Action):
66         """
67         Parse and split the '--test-params' argument.
68
69         This expects either 'x=y', 'x=y,z' or 'x' (implicit true)
70         values. For multiple overrides use a ; separated list for
71         e.g. --test-params 'x=z; y=a,b'
72         """
73         def __call__(self, parser, namespace, values, option_string=None):
74             results = {}
75
76             for value in values.split(';'):
77                 result = [key.strip() for key in value.split('=')]
78                 if len(result) == 1:
79                     results[result[0]] = True
80                 elif len(result) == 2:
81                     results[result[0]] = result[1]
82                 else:
83                     raise argparse.ArgumentTypeError(
84                         'expected \'%s\' to be of format \'key=val\' or'
85                         ' \'key\'' % result)
86
87             setattr(namespace, self.dest, results)
88
89     class _ValidateFileAction(argparse.Action):
90         """Validate a file can be read from before using it.
91         """
92         def __call__(self, parser, namespace, values, option_string=None):
93             if not os.path.isfile(values):
94                 raise argparse.ArgumentTypeError(
95                     'the path \'%s\' is not a valid path' % values)
96             elif not os.access(values, os.R_OK):
97                 raise argparse.ArgumentTypeError(
98                     'the path \'%s\' is not accessible' % values)
99
100             setattr(namespace, self.dest, values)
101
102     class _ValidateDirAction(argparse.Action):
103         """Validate a directory can be written to before using it.
104         """
105         def __call__(self, parser, namespace, values, option_string=None):
106             if not os.path.isdir(values):
107                 raise argparse.ArgumentTypeError(
108                     'the path \'%s\' is not a valid path' % values)
109             elif not os.access(values, os.W_OK):
110                 raise argparse.ArgumentTypeError(
111                     'the path \'%s\' is not accessible' % values)
112
113             setattr(namespace, self.dest, values)
114
115     def list_logging_levels():
116         """Give a summary of all available logging levels.
117
118         :return: List of verbosity level names in decreasing order of
119             verbosity
120         """
121         return sorted(VERBOSITY_LEVELS.keys(),
122                       key=lambda x: VERBOSITY_LEVELS[x])
123
124     parser = argparse.ArgumentParser(prog=__file__, formatter_class=
125                                      argparse.ArgumentDefaultsHelpFormatter)
126     parser.add_argument('--version', action='version', version='%(prog)s 0.2')
127     parser.add_argument('--list', '--list-tests', action='store_true',
128                         help='list all tests and exit')
129     parser.add_argument('--list-trafficgens', action='store_true',
130                         help='list all traffic generators and exit')
131     parser.add_argument('--list-collectors', action='store_true',
132                         help='list all system metrics loggers and exit')
133     parser.add_argument('--list-vswitches', action='store_true',
134                         help='list all system vswitches and exit')
135     parser.add_argument('--list-fwdapps', action='store_true',
136                         help='list all system forwarding applications and exit')
137     parser.add_argument('--list-vnfs', action='store_true',
138                         help='list all system vnfs and exit')
139     parser.add_argument('--list-settings', action='store_true',
140                         help='list effective settings configuration and exit')
141     parser.add_argument('exact_test_name', nargs='*', help='Exact names of\
142             tests to run. E.g "vsperf phy2phy_tput phy2phy_cont"\
143             runs only the two tests with those exact names.\
144             To run all tests omit both positional args and --tests arg.')
145
146     group = parser.add_argument_group('test selection options')
147     group.add_argument('-m', '--mode', help='vsperf mode of operation;\
148             Values: "normal" - execute vSwitch, VNF and traffic generator;\
149             "trafficgen" - execute only traffic generator; "trafficgen-off" \
150             - execute vSwitch and VNF; trafficgen-pause - execute vSwitch \
151             and VNF but pause before traffic transmission ', default='normal')
152
153     group.add_argument('-f', '--test-spec', help='test specification file')
154     group.add_argument('-d', '--test-dir', help='directory containing tests')
155     group.add_argument('-t', '--tests', help='Comma-separated list of terms \
156             indicating tests to run. e.g. "RFC2544,!p2p" - run all tests whose\
157             name contains RFC2544 less those containing "p2p"')
158     group.add_argument('--verbosity', choices=list_logging_levels(),
159                        help='debug level')
160     group.add_argument('--integration', action='store_true', help='execute integration tests')
161     group.add_argument('--trafficgen', help='traffic generator to use')
162     group.add_argument('--vswitch', help='vswitch implementation to use')
163     group.add_argument('--fwdapp', help='packet forwarding application to use')
164     group.add_argument('--vnf', help='vnf to use')
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     # Load non performance/integration tests
348     if args['integration']:
349         settings.load_from_dir('conf/integration')
350
351     # load command line parameters first in case there are settings files
352     # to be used
353     settings.load_from_dict(args)
354
355     if args['conf_file']:
356         settings.load_from_file(args['conf_file'])
357
358     if args['load_env']:
359         settings.load_from_env()
360
361     # reload command line parameters since these should take higher priority
362     # than both a settings file and environment variables
363     settings.load_from_dict(args)
364
365     vswitch_none = False
366     # set dpdk and ovs paths accorfing to VNF and VSWITCH
367     if settings.getValue('VSWITCH').endswith('Vanilla'):
368         # settings paths for Vanilla
369         settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_VANILLA')))
370     elif settings.getValue('VSWITCH').endswith('Vhost'):
371         if settings.getValue('VNF').endswith('Cuse'):
372             # settings paths for Cuse
373             settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_CUSE')))
374             settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_CUSE')))
375         else:
376             # settings paths for VhostUser
377             settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_USER')))
378             settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_USER')))
379     else:
380         # default - set to VHOST USER but can be changed during enhancement
381         settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_USER')))
382         settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_USER')))
383         if 'none' == settings.getValue('VSWITCH').strip().lower():
384             vswitch_none = True
385
386     configure_logging(settings.getValue('VERBOSITY'))
387     logger = logging.getLogger()
388
389     # check and fix locale
390     check_and_set_locale()
391
392     # configure trafficgens
393     if args['trafficgen']:
394         trafficgens = Loader().get_trafficgens()
395         if args['trafficgen'] not in trafficgens:
396             logging.error('There are no trafficgens matching \'%s\' found in'
397                           ' \'%s\'. Exiting...', args['trafficgen'],
398                           settings.getValue('TRAFFICGEN_DIR'))
399             sys.exit(1)
400
401     # configure vswitch
402     if args['vswitch']:
403         vswitch_none = 'none' == args['vswitch'].strip().lower()
404         if vswitch_none:
405             settings.setValue('VSWITCH', 'none')
406         else:
407             vswitches = Loader().get_vswitches()
408             if args['vswitch'] not in vswitches:
409                 logging.error('There are no vswitches matching \'%s\' found in'
410                               ' \'%s\'. Exiting...', args['vswitch'],
411                               settings.getValue('VSWITCH_DIR'))
412                 sys.exit(1)
413
414     if args['fwdapp']:
415         settings.setValue('PKTFWD', args['fwdapp'])
416         fwdapps = Loader().get_pktfwds()
417         if args['fwdapp'] not in fwdapps:
418             logging.error('There are no forwarding application'
419                           ' matching \'%s\' found in'
420                           ' \'%s\'. Exiting...', args['fwdapp'],
421                           settings.getValue('PKTFWD_DIR'))
422             sys.exit(1)
423
424     if args['vnf']:
425         vnfs = Loader().get_vnfs()
426         if args['vnf'] not in vnfs:
427             logging.error('there are no vnfs matching \'%s\' found in'
428                           ' \'%s\'. exiting...', args['vnf'],
429                           settings.getValue('vnf_dir'))
430             sys.exit(1)
431
432     # update global settings
433     guest_loopback = get_test_param('guest_loopback', None)
434     if guest_loopback:
435         tmp_gl = []
436         for i in range(len(settings.getValue('GUEST_LOOPBACK'))):
437             tmp_gl.append(guest_loopback)
438         settings.setValue('GUEST_LOOPBACK', tmp_gl)
439
440     settings.setValue('mode', args['mode'])
441
442     # generate results directory name
443     date = datetime.datetime.fromtimestamp(time.time())
444     results_dir = "results_" + date.strftime('%Y-%m-%d_%H-%M-%S')
445     results_path = os.path.join(settings.getValue('LOG_DIR'), results_dir)
446
447     # create results directory
448     if not os.path.exists(results_path):
449         logger.info("Creating result directory: "  + results_path)
450         os.makedirs(results_path)
451
452     if settings.getValue('mode') == 'trafficgen':
453         # execute only traffic generator
454         logging.debug("Executing traffic generator:")
455         loader = Loader()
456         # set traffic details, so they can be passed to traffic ctl
457         traffic = copy.deepcopy(TRAFFIC_DEFAULTS)
458         traffic.update({'traffic_type': get_test_param('traffic_type', 'rfc2544'),
459                         'bidir': get_test_param('bidirectional', False),
460                         'multistream': int(get_test_param('multistream', 0)),
461                         'stream_type': get_test_param('stream_type', 'L4'),
462                         'frame_rate': int(get_test_param('iload', 100))})
463
464         traffic_ctl = component_factory.create_traffic(
465             traffic['traffic_type'],
466             loader.get_trafficgen_class())
467         with traffic_ctl:
468             traffic_ctl.send_traffic(traffic)
469         logging.debug("Traffic Results:")
470         traffic_ctl.print_results()
471     else:
472         # configure tests
473         if args['integration']:
474             testcases = settings.getValue('INTEGRATION_TESTS')
475         else:
476             testcases = settings.getValue('PERFORMANCE_TESTS')
477
478         all_tests = []
479         for cfg in testcases:
480             try:
481                 if args['integration']:
482                     all_tests.append(IntegrationTestCase(cfg, results_path))
483                 else:
484                     all_tests.append(PerformanceTestCase(cfg, results_path))
485             except (Exception) as _:
486                 logger.exception("Failed to create test: %s",
487                                  cfg.get('Name', '<Name not set>'))
488                 raise
489
490         # if required, handle list-* operations
491
492         if args['list']:
493             print("Available Tests:")
494             print("================")
495             for test in all_tests:
496                 print('* %-30s %s' % ('%s:' % test.name, test.desc))
497             exit()
498
499         if args['list_trafficgens']:
500             print(Loader().get_trafficgens_printable())
501             exit()
502
503         if args['list_collectors']:
504             print(Loader().get_collectors_printable())
505             exit()
506
507         if args['list_vswitches']:
508             print(Loader().get_vswitches_printable())
509             exit()
510
511         if args['list_vnfs']:
512             print(Loader().get_vnfs_printable())
513             exit()
514
515         if args['list_settings']:
516             print(str(settings))
517             exit()
518
519         # select requested tests
520         if args['exact_test_name'] and args['tests']:
521             logger.error("Cannot specify tests with both positional args and --test.")
522             sys.exit(1)
523
524         if args['exact_test_name']:
525             exact_names = args['exact_test_name']
526             # positional args => exact matches only
527             selected_tests = [test for test in all_tests if test.name in exact_names]
528         elif args['tests']:
529             # --tests => apply filter to select requested tests
530             selected_tests = apply_filter(all_tests, args['tests'])
531         else:
532             # Default - run all tests
533             selected_tests = all_tests
534
535         if not selected_tests:
536             logger.error("No tests matched --test option or positional args. Done.")
537             sys.exit(1)
538
539         # run tests
540         suite = unittest.TestSuite()
541         for test in selected_tests:
542             try:
543                 test.run()
544                 suite.addTest(MockTestCase('', True, test.name))
545             #pylint: disable=broad-except
546             except (Exception) as ex:
547                 logger.exception("Failed to run test: %s", test.name)
548                 suite.addTest(MockTestCase(str(ex), False, test.name))
549                 logger.info("Continuing with next test...")
550
551         # generate final rst report with results of all executed TCs
552         generate_final_report(results_path)
553
554         if settings.getValue('XUNIT'):
555             xmlrunner.XMLTestRunner(
556                 output=settings.getValue('XUNIT_DIR'), outsuffix="",
557                 verbosity=0).run(suite)
558
559         if args['opnfvpod']:
560             pod_name = args['opnfvpod']
561             installer_name = settings.getValue('OPNFV_INSTALLER')
562             opnfv_url = settings.getValue('OPNFV_URL')
563             pkg_list = settings.getValue('PACKAGE_LIST')
564
565             int_data = {'cuse': False,
566                         'vanilla': False,
567                         'pod': pod_name,
568                         'installer': installer_name,
569                         'pkg_list': pkg_list,
570                         'db_url': opnfv_url}
571             if settings.getValue('VSWITCH').endswith('Vanilla'):
572                 int_data['vanilla'] = True
573             if settings.getValue('VNF').endswith('Cuse'):
574                 int_data['cuse'] = True
575             opnfvdashboard.results2opnfv_dashboard(results_path, int_data)
576
577     #remove directory if no result files were created.
578     if os.path.exists(results_path):
579         files_list = os.listdir(results_path)
580         if files_list == []:
581             shutil.rmtree(results_path)
582
583 if __name__ == "__main__":
584     main()
585
586