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