f6ddc637f47a7294803cf3144149297f480ec6f2
[vswitchperf.git] / vsperf
1 #!/usr/bin/env python3
2
3 # Copyright 2015 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
30 sys.dont_write_bytecode = True
31
32 from conf import settings
33 from core.loader import Loader
34 from testcases import TestCase
35 from tools import tasks
36 from tools.pkt_gen import trafficgen
37 from tools.opnfvdashboard import opnfvdashboard
38
39 VERBOSITY_LEVELS = {
40     'debug': logging.DEBUG,
41     'info': logging.INFO,
42     'warning': logging.WARNING,
43     'error': logging.ERROR,
44     'critical': logging.CRITICAL
45 }
46
47
48 def parse_arguments():
49     """
50     Parse command line arguments.
51     """
52     class _SplitTestParamsAction(argparse.Action):
53         """
54         Parse and split the '--test-params' argument.
55
56         This expects either 'x=y', 'x=y,z' or 'x' (implicit true)
57         values. For multiple overrides use a ; separated list for
58         e.g. --test-params 'x=z; y=a,b'
59         """
60         def __call__(self, parser, namespace, values, option_string=None):
61             results = {}
62
63             for value in values.split(';'):
64                 result = [key.strip() for key in value.split('=')]
65                 if len(result) == 1:
66                     results[result[0]] = True
67                 elif len(result) == 2:
68                     results[result[0]] = result[1]
69                 else:
70                     raise argparse.ArgumentTypeError(
71                         'expected \'%s\' to be of format \'key=val\' or'
72                         ' \'key\'' % result)
73
74             setattr(namespace, self.dest, results)
75
76     class _ValidateFileAction(argparse.Action):
77         """Validate a file can be read from before using it.
78         """
79         def __call__(self, parser, namespace, values, option_string=None):
80             if not os.path.isfile(values):
81                 raise argparse.ArgumentTypeError(
82                     'the path \'%s\' is not a valid path' % values)
83             elif not os.access(values, os.R_OK):
84                 raise argparse.ArgumentTypeError(
85                     'the path \'%s\' is not accessible' % values)
86
87             setattr(namespace, self.dest, values)
88
89     class _ValidateDirAction(argparse.Action):
90         """Validate a directory can be written to before using it.
91         """
92         def __call__(self, parser, namespace, values, option_string=None):
93             if not os.path.isdir(values):
94                 raise argparse.ArgumentTypeError(
95                     'the path \'%s\' is not a valid path' % values)
96             elif not os.access(values, os.W_OK):
97                 raise argparse.ArgumentTypeError(
98                     'the path \'%s\' is not accessible' % values)
99
100             setattr(namespace, self.dest, values)
101
102     def list_logging_levels():
103         """Give a summary of all available logging levels.
104
105         :return: List of verbosity level names in decreasing order of
106             verbosity
107         """
108         return sorted(VERBOSITY_LEVELS.keys(),
109                       key=lambda x: VERBOSITY_LEVELS[x])
110
111     parser = argparse.ArgumentParser(prog=__file__, formatter_class=
112                                      argparse.ArgumentDefaultsHelpFormatter)
113     parser.add_argument('--version', action='version', version='%(prog)s 0.2')
114     parser.add_argument('--list', '--list-tests', action='store_true',
115                         help='list all tests and exit')
116     parser.add_argument('--list-trafficgens', action='store_true',
117                         help='list all traffic generators and exit')
118     parser.add_argument('--list-collectors', action='store_true',
119                         help='list all system metrics loggers and exit')
120     parser.add_argument('--list-vswitches', action='store_true',
121                         help='list all system vswitches and exit')
122     parser.add_argument('--list-vnfs', action='store_true',
123                         help='list all system vnfs and exit')
124     parser.add_argument('--list-settings', action='store_true',
125                         help='list effective settings configuration and exit')
126     parser.add_argument('exact_test_name', nargs='*', help='Exact names of\
127             tests to run. E.g "vsperf phy2phy_tput phy2phy_cont"\
128             runs only the two tests with those exact names.\
129             To run all tests omit both positional args and --tests arg.')
130
131     group = parser.add_argument_group('test selection options')
132     group.add_argument('-f', '--test-spec', help='test specification file')
133     group.add_argument('-d', '--test-dir', help='directory containing tests')
134     group.add_argument('-t', '--tests', help='Comma-separated list of terms \
135             indicating tests to run. e.g. "RFC2544,!p2p" - run all tests whose\
136             name contains RFC2544 less those containing "p2p"')
137     group.add_argument('--verbosity', choices=list_logging_levels(),
138                        help='debug level')
139     group.add_argument('--trafficgen', help='traffic generator to use')
140     group.add_argument('--vswitch', help='vswitch implementation to use')
141     group.add_argument('--vnf', help='vnf to use')
142     group.add_argument('--duration', help='traffic transmit duration')
143     group.add_argument('--sysmetrics', help='system metrics logger to use')
144     group = parser.add_argument_group('test behavior options')
145     group.add_argument('--xunit', action='store_true',
146                        help='enable xUnit-formatted output')
147     group.add_argument('--xunit-dir', action=_ValidateDirAction,
148                        help='output directory of xUnit-formatted output')
149     group.add_argument('--load-env', action='store_true',
150                        help='enable loading of settings from the environment')
151     group.add_argument('--conf-file', action=_ValidateFileAction,
152                        help='settings file')
153     group.add_argument('--test-params', action=_SplitTestParamsAction,
154                        help='csv list of test parameters: key=val; e.g.'
155                        'including pkt_sizes=x,y; duration=x; '
156                        'rfc2544_trials=x ...')
157     group.add_argument('--opnfvpod', help='name of POD in opnfv')
158
159     args = vars(parser.parse_args())
160
161     return args
162
163
164 def configure_logging(level):
165     """Configure logging.
166     """
167     log_file_default = os.path.join(
168         settings.getValue('LOG_DIR'), settings.getValue('LOG_FILE_DEFAULT'))
169     log_file_host_cmds = os.path.join(
170         settings.getValue('LOG_DIR'), settings.getValue('LOG_FILE_HOST_CMDS'))
171     log_file_traffic_gen = os.path.join(
172         settings.getValue('LOG_DIR'),
173         settings.getValue('LOG_FILE_TRAFFIC_GEN'))
174
175     logger = logging.getLogger()
176     logger.setLevel(logging.DEBUG)
177
178     stream_logger = logging.StreamHandler(sys.stdout)
179     stream_logger.setLevel(VERBOSITY_LEVELS[level])
180     stream_logger.setFormatter(logging.Formatter(
181         '[%(levelname)s]  %(asctime)s : (%(name)s) - %(message)s'))
182     logger.addHandler(stream_logger)
183
184     file_logger = logging.FileHandler(filename=log_file_default)
185     file_logger.setLevel(logging.DEBUG)
186     logger.addHandler(file_logger)
187
188     class CommandFilter(logging.Filter):
189         """Filter out strings beginning with 'cmd :'"""
190         def filter(self, record):
191             return record.getMessage().startswith(tasks.CMD_PREFIX)
192
193     class TrafficGenCommandFilter(logging.Filter):
194         """Filter out strings beginning with 'gencmd :'"""
195         def filter(self, record):
196             return record.getMessage().startswith(trafficgen.CMD_PREFIX)
197
198     cmd_logger = logging.FileHandler(filename=log_file_host_cmds)
199     cmd_logger.setLevel(logging.DEBUG)
200     cmd_logger.addFilter(CommandFilter())
201     logger.addHandler(cmd_logger)
202
203     gen_logger = logging.FileHandler(filename=log_file_traffic_gen)
204     gen_logger.setLevel(logging.DEBUG)
205     gen_logger.addFilter(TrafficGenCommandFilter())
206     logger.addHandler(gen_logger)
207
208
209 def apply_filter(tests, tc_filter):
210     """Allow a subset of tests to be conveniently selected
211
212     :param tests: The list of Tests from which to select.
213     :param tc_filter: A case-insensitive string of comma-separated terms
214         indicating the Tests to select.
215         e.g. 'RFC' - select all tests whose name contains 'RFC'
216         e.g. 'RFC,burst' - select all tests whose name contains 'RFC' or
217             'burst'
218         e.g. 'RFC,burst,!p2p' - select all tests whose name contains 'RFC'
219             or 'burst' and from these remove any containing 'p2p'.
220         e.g. '' - empty string selects all tests.
221     :return: A list of the selected Tests.
222     """
223     result = []
224     if tc_filter is None:
225         tc_filter = ""
226
227     for term in [x.strip() for x in tc_filter.lower().split(",")]:
228         if not term or term[0] != '!':
229             # Add matching tests from 'tests' into results
230             result.extend([test for test in tests \
231                 if test.name.lower().find(term) >= 0])
232         else:
233             # Term begins with '!' so we remove matching tests
234             result = [test for test in result \
235                 if test.name.lower().find(term[1:]) < 0]
236
237     return result
238
239
240 class MockTestCase(unittest.TestCase):
241     """Allow use of xmlrunner to generate Jenkins compatible output without
242     using xmlrunner to actually run tests.
243
244     Usage:
245         suite = unittest.TestSuite()
246         suite.addTest(MockTestCase('Test1 passed ', True, 'Test1'))
247         suite.addTest(MockTestCase('Test2 failed because...', False, 'Test2'))
248         xmlrunner.XMLTestRunner(...).run(suite)
249     """
250
251     def __init__(self, msg, is_pass, test_name):
252         #remember the things
253         self.msg = msg
254         self.is_pass = is_pass
255
256         #dynamically create a test method with the right name
257         #but point the method at our generic test method
258         setattr(MockTestCase, test_name, self.generic_test)
259
260         super(MockTestCase, self).__init__(test_name)
261
262     def generic_test(self):
263         """Provide a generic function that raises or not based
264         on how self.is_pass was set in the constructor"""
265         self.assertTrue(self.is_pass, self.msg)
266
267
268 def main():
269     """Main function.
270     """
271     args = parse_arguments()
272
273     # configure settings
274
275     settings.load_from_dir('conf')
276
277     # load command line parameters first in case there are settings files
278     # to be used
279     settings.load_from_dict(args)
280
281     if args['conf_file']:
282         settings.load_from_file(args['conf_file'])
283
284     if args['load_env']:
285         settings.load_from_env()
286
287     # reload command line parameters since these should take higher priority
288     # than both a settings file and environment variables
289     settings.load_from_dict(args)
290
291     # set dpdk and ovs paths accorfing to VNF and VSWITCH
292     if settings.getValue('VSWITCH').endswith('Vanilla'):
293         # settings paths for Vanilla
294         settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_VANILLA')))
295     elif settings.getValue('VSWITCH').endswith('Vhost'):
296         if settings.getValue('VNF').endswith('Cuse'):
297             # settings paths for Cuse
298             settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_CUSE')))
299             settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_CUSE')))
300         else:
301             # settings paths for VhostUser
302             settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_USER')))
303             settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_USER')))
304     else:
305         # default - set to VHOST USER but can be changed during enhancement
306         settings.setValue('RTE_SDK', (settings.getValue('RTE_SDK_USER')))
307         settings.setValue('OVS_DIR', (settings.getValue('OVS_DIR_USER')))
308
309     configure_logging(settings.getValue('VERBOSITY'))
310     logger = logging.getLogger()
311
312     # configure trafficgens
313
314     if args['trafficgen']:
315         trafficgens = Loader().get_trafficgens()
316         if args['trafficgen'] not in trafficgens:
317             logging.error('There are no trafficgens matching \'%s\' found in'
318                           ' \'%s\'. Exiting...', args['trafficgen'],
319                           settings.getValue('TRAFFICGEN_DIR'))
320             sys.exit(1)
321
322     # configure vswitch
323     if args['vswitch']:
324         vswitches = Loader().get_vswitches()
325         if args['vswitch'] not in vswitches:
326             logging.error('There are no vswitches matching \'%s\' found in'
327                           ' \'%s\'. Exiting...', args['vswitch'],
328                           settings.getValue('VSWITCH_DIR'))
329             sys.exit(1)
330
331     if args['vnf']:
332         vnfs = Loader().get_vnfs()
333         if args['vnf'] not in vnfs:
334             logging.error('there are no vnfs matching \'%s\' found in'
335                           ' \'%s\'. exiting...', args['vnf'],
336                           settings.getValue('vnf_dir'))
337             sys.exit(1)
338
339     if args['duration']:
340         if args['duration'].isdigit() and int(args['duration']) > 0:
341             settings.setValue('duration', args['duration'])
342         else:
343             logging.error('The selected Duration is not a number')
344             sys.exit(1)
345
346     # generate results directory name
347     date = datetime.datetime.fromtimestamp(time.time())
348     results_dir = "results_" + date.strftime('%Y-%m-%d_%H-%M-%S')
349     results_path = os.path.join(settings.getValue('LOG_DIR'), results_dir)
350
351     # configure tests
352     testcases = settings.getValue('PERFORMANCE_TESTS')
353     all_tests = []
354     for cfg in testcases:
355         try:
356             all_tests.append(TestCase(cfg, results_path))
357         except (Exception) as _:
358             logger.exception("Failed to create test: %s",
359                              cfg.get('Name', '<Name not set>'))
360             raise
361
362     # if required, handle list-* operations
363
364     if args['list']:
365         print("Available Tests:")
366         print("======")
367         for test in all_tests:
368             print('* %-18s%s' % ('%s:' % test.name, test.desc))
369         exit()
370
371     if args['list_trafficgens']:
372         print(Loader().get_trafficgens_printable())
373         exit()
374
375     if args['list_collectors']:
376         print(Loader().get_collectors_printable())
377         exit()
378
379     if args['list_vswitches']:
380         print(Loader().get_vswitches_printable())
381         exit()
382
383     if args['list_vnfs']:
384         print(Loader().get_vnfs_printable())
385         exit()
386
387     if args['list_settings']:
388         print(str(settings))
389         exit()
390
391     # select requested tests
392     if args['exact_test_name'] and args['tests']:
393         logger.error("Cannot specify tests with both positional args and --test.")
394         sys.exit(1)
395
396     if args['exact_test_name']:
397         exact_names = args['exact_test_name']
398         # positional args => exact matches only
399         selected_tests = [test for test in all_tests if test.name in exact_names]
400     elif args['tests']:
401         # --tests => apply filter to select requested tests
402         selected_tests = apply_filter(all_tests, args['tests'])
403     else:
404         # Default - run all tests
405         selected_tests = all_tests
406
407     if not selected_tests:
408         logger.error("No tests matched --test option or positional args. Done.")
409         sys.exit(1)
410
411     # create results directory
412     if not os.path.exists(results_path):
413         logger.info("Creating result directory: "  + results_path)
414         os.makedirs(results_path)
415
416     # run tests
417     suite = unittest.TestSuite()
418     for test in selected_tests:
419         try:
420             test.run()
421             suite.addTest(MockTestCase('', True, test.name))
422         #pylint: disable=broad-except
423         except (Exception) as ex:
424             logger.exception("Failed to run test: %s", test.name)
425             suite.addTest(MockTestCase(str(ex), False, test.name))
426             logger.info("Continuing with next test...")
427
428     if settings.getValue('XUNIT'):
429         xmlrunner.XMLTestRunner(
430             output=settings.getValue('XUNIT_DIR'), outsuffix="",
431             verbosity=0).run(suite)
432
433     if args['opnfvpod']:
434         pod_name = args['opnfvpod']
435         installer_name = settings.getValue('OPNFV_INSTALLER')
436
437         int_data = {'cuse': False,
438                     'vanilla': False,
439                     'pod': pod_name,
440                     'installer': installer_name}
441         if settings.getValue('VSWITCH').endswith('Vanilla'):
442             int_data['vanilla'] = True
443         if settings.getValue('VNF').endswith('Cuse'):
444             int_data['cuse'] = True
445         opnfvdashboard.results2opnfv_dashboard(results_path, int_data)
446
447     #remove directory if no result files were created.
448     if os.path.exists(results_path):
449         files_list = os.listdir(results_path)
450         if files_list == []:
451             shutil.rmtree(results_path)
452
453 if __name__ == "__main__":
454     main()
455
456