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