Add a simple performance test that sends a continuous stream
[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('--sysmetrics', help='system metrics logger to use')
134     group = parser.add_argument_group('test behavior options')
135     group.add_argument('--xunit', action='store_true',
136                        help='enable xUnit-formatted output')
137     group.add_argument('--xunit-dir', action=_ValidateDirAction,
138                        help='output directory of xUnit-formatted output')
139     group.add_argument('--load-env', action='store_true',
140                        help='enable loading of settings from the environment')
141     group.add_argument('--conf-file', action=_ValidateFileAction,
142                        help='settings file')
143     group.add_argument('--test-params', action=_SplitTestParamsAction,
144                        help='csv list of test parameters: key=val;...')
145
146     args = vars(parser.parse_args())
147
148     return args
149
150
151 def configure_logging(level):
152     """Configure logging.
153     """
154     log_file_default = os.path.join(
155         settings.getValue('LOG_DIR'), settings.getValue('LOG_FILE_DEFAULT'))
156     log_file_host_cmds = os.path.join(
157         settings.getValue('LOG_DIR'), settings.getValue('LOG_FILE_HOST_CMDS'))
158     log_file_traffic_gen = os.path.join(
159         settings.getValue('LOG_DIR'),
160         settings.getValue('LOG_FILE_TRAFFIC_GEN'))
161     log_file_sys_metrics = os.path.join(
162         settings.getValue('LOG_DIR'),
163         settings.getValue('LOG_FILE_SYS_METRICS'))
164
165     logger = logging.getLogger()
166     logger.setLevel(logging.DEBUG)
167
168     stream_logger = logging.StreamHandler(sys.stdout)
169     stream_logger.setLevel(VERBOSITY_LEVELS[level])
170     stream_logger.setFormatter(logging.Formatter(
171         '[%(levelname)s]  %(asctime)s : (%(name)s) - %(message)s'))
172     logger.addHandler(stream_logger)
173
174     file_logger = logging.FileHandler(filename=log_file_default)
175     file_logger.setLevel(logging.DEBUG)
176     logger.addHandler(file_logger)
177
178     class CommandFilter(logging.Filter):
179         """Filter out strings beginning with 'cmd :'"""
180         def filter(self, record):
181             return record.getMessage().startswith(tasks.CMD_PREFIX)
182
183     class TrafficGenCommandFilter(logging.Filter):
184         """Filter out strings beginning with 'gencmd :'"""
185         def filter(self, record):
186             return record.getMessage().startswith(trafficgen.CMD_PREFIX)
187
188     class SystemMetricsCommandFilter(logging.Filter):
189         """Filter out strings beginning with 'gencmd :'"""
190         def filter(self, record):
191             return record.getMessage().startswith(collector.CMD_PREFIX)
192
193     cmd_logger = logging.FileHandler(filename=log_file_host_cmds)
194     cmd_logger.setLevel(logging.DEBUG)
195     cmd_logger.addFilter(CommandFilter())
196     logger.addHandler(cmd_logger)
197
198     gen_logger = logging.FileHandler(filename=log_file_traffic_gen)
199     gen_logger.setLevel(logging.DEBUG)
200     gen_logger.addFilter(TrafficGenCommandFilter())
201     logger.addHandler(gen_logger)
202
203     metrics_logger = logging.FileHandler(filename=log_file_sys_metrics)
204     metrics_logger.setLevel(logging.DEBUG)
205     metrics_logger.addFilter(SystemMetricsCommandFilter())
206     logger.addHandler(metrics_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     configure_logging(settings.getValue('VERBOSITY'))
292     logger = logging.getLogger()
293
294     # configure trafficgens
295
296     if args['trafficgen']:
297         trafficgens = Loader().get_trafficgens()
298         if args['trafficgen'] not in trafficgens:
299             logging.error('There are no trafficgens matching \'%s\' found in'
300                           ' \'%s\'. Exiting...', args['trafficgen'],
301                           settings.getValue('TRAFFICGEN_DIR'))
302             sys.exit(1)
303
304
305     # generate results directory name
306     date = datetime.datetime.fromtimestamp(time.time())
307     results_dir = "results_" + date.strftime('%Y-%m-%d_%H-%M-%S')
308     results_path = os.path.join(settings.getValue('LOG_DIR'), results_dir)
309
310     # configure tests
311     testcases = settings.getValue('PERFORMANCE_TESTS')
312     all_tests = []
313     for cfg in testcases:
314         try:
315             all_tests.append(TestCase(cfg, results_path))
316         except (Exception) as _:
317             logger.exception("Failed to create test: %s",
318                              cfg.get('Name', '<Name not set>'))
319             raise
320
321     # TODO(BOM) Apply filter to select requested tests
322     all_tests = apply_filter(all_tests, args['tests'])
323
324     # if required, handle list-* operations
325
326     if args['list']:
327         print("Available Tests:")
328         print("======")
329         for test in all_tests:
330             print('* %-18s%s' % ('%s:' % test.name, test.desc))
331         exit()
332
333     if args['list_trafficgens']:
334         print(Loader().get_trafficgens_printable())
335         exit()
336
337     if args['list_collectors']:
338         print(Loader().get_collectors_printable())
339         exit()
340
341     if args['list_vswitches']:
342         print(Loader().get_vswitches_printable())
343         exit()
344
345     if args['list_settings']:
346         print(str(settings))
347         exit()
348
349     # create results directory
350     if not os.path.exists(results_dir):
351         logger.info("Creating result directory: "  + results_path)
352         os.makedirs(results_path)
353
354     suite = unittest.TestSuite()
355
356     # run tests
357     for test in all_tests:
358         try:
359             test.run()
360             suite.addTest(MockTestCase('', True, test.name))
361         #pylint: disable=broad-except
362         except (Exception) as ex:
363             logger.exception("Failed to run test: %s", test.name)
364             suite.addTest(MockTestCase(str(ex), False, test.name))
365             logger.info("Continuing with next test...")
366
367     if settings.getValue('XUNIT'):
368         xmlrunner.XMLTestRunner(
369             output=settings.getValue('XUNIT_DIR'), outsuffix="",
370             verbosity=0).run(suite)
371
372     #remove directory if no result files were created.
373     if os.path.exists(results_path):
374         if os.listdir(results_path) == []:
375             shutil.rmtree(results_path)
376
377 if __name__ == "__main__":
378     main()
379
380