test_spec: Note need for 3 test ports in RFC2889 address/cache
[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     configure_logging(settings.getValue('VERBOSITY'))
290     logger = logging.getLogger()
291
292     # configure trafficgens
293
294     if args['trafficgen']:
295         trafficgens = Loader().get_trafficgens()
296         if args['trafficgen'] not in trafficgens:
297             logging.error('There are no trafficgens matching \'%s\' found in'
298                           ' \'%s\'. Exiting...', args['trafficgen'],
299                           settings.getValue('TRAFFICGEN_DIR'))
300             sys.exit(1)
301
302     # configure vswitch
303     if args['vswitch']:
304         vswitches = Loader().get_vswitches()
305         if args['vswitch'] not in vswitches:
306             logging.error('There are no vswitches matching \'%s\' found in'
307                           ' \'%s\'. Exiting...', args['vswitch'],
308                           settings.getValue('VSWITCH_DIR'))
309             sys.exit(1)
310
311     if args['vnf']:
312         vnfs = Loader().get_vnfs()
313         if args['vnf'] not in vnfs:
314             logging.error('there are no vnfs matching \'%s\' found in'
315                           ' \'%s\'. exiting...', args['vnf'],
316                           settings.getValue('vnf_dir'))
317             sys.exit(1)
318
319     if args['duration']:
320         if args['duration'].isdigit() and int(args['duration']) > 0:
321             settings.setValue('duration', args['duration'])
322         else:
323             logging.error('The selected Duration is not a number')
324             sys.exit(1)
325
326     # generate results directory name
327     date = datetime.datetime.fromtimestamp(time.time())
328     results_dir = "results_" + date.strftime('%Y-%m-%d_%H-%M-%S')
329     results_path = os.path.join(settings.getValue('LOG_DIR'), results_dir)
330
331     # configure tests
332     testcases = settings.getValue('PERFORMANCE_TESTS')
333     all_tests = []
334     for cfg in testcases:
335         try:
336             all_tests.append(TestCase(cfg, results_path))
337         except (Exception) as _:
338             logger.exception("Failed to create test: %s",
339                              cfg.get('Name', '<Name not set>'))
340             raise
341
342     # if required, handle list-* operations
343
344     if args['list']:
345         print("Available Tests:")
346         print("======")
347         for test in all_tests:
348             print('* %-18s%s' % ('%s:' % test.name, test.desc))
349         exit()
350
351     if args['list_trafficgens']:
352         print(Loader().get_trafficgens_printable())
353         exit()
354
355     if args['list_collectors']:
356         print(Loader().get_collectors_printable())
357         exit()
358
359     if args['list_vswitches']:
360         print(Loader().get_vswitches_printable())
361         exit()
362
363     if args['list_vnfs']:
364         print(Loader().get_vnfs_printable())
365         exit()
366
367     if args['list_settings']:
368         print(str(settings))
369         exit()
370
371     # select requested tests
372     if args['exact_test_name'] and args['tests']:
373         logger.error("Cannot specify tests with both positional args and --test.")
374         sys.exit(1)
375
376     if args['exact_test_name']:
377         exact_names = args['exact_test_name']
378         # positional args => exact matches only
379         selected_tests = [test for test in all_tests if test.name in exact_names]
380     elif args['tests']:
381         # --tests => apply filter to select requested tests
382         selected_tests = apply_filter(all_tests, args['tests'])
383     else:
384         # Default - run all tests
385         selected_tests = all_tests
386
387     if not selected_tests:
388         logger.error("No tests matched --test option or positional args. Done.")
389         sys.exit(1)
390
391     # create results directory
392     if not os.path.exists(results_path):
393         logger.info("Creating result directory: "  + results_path)
394         os.makedirs(results_path)
395
396     # run tests
397     suite = unittest.TestSuite()
398     for test in selected_tests:
399         try:
400             test.run()
401             suite.addTest(MockTestCase('', True, test.name))
402         #pylint: disable=broad-except
403         except (Exception) as ex:
404             logger.exception("Failed to run test: %s", test.name)
405             suite.addTest(MockTestCase(str(ex), False, test.name))
406             logger.info("Continuing with next test...")
407
408     if settings.getValue('XUNIT'):
409         xmlrunner.XMLTestRunner(
410             output=settings.getValue('XUNIT_DIR'), outsuffix="",
411             verbosity=0).run(suite)
412
413     #remove directory if no result files were created.
414     if os.path.exists(results_path):
415         files_list = os.listdir(results_path)
416         if files_list == []:
417             shutil.rmtree(results_path)
418
419 if __name__ == "__main__":
420     main()
421
422