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