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