Add a configurable pause between runs during NDR/PDR binary search
[nfvbench.git] / nfvbench / nfvbench.py
1 #!/usr/bin/env python
2 # Copyright 2016 Cisco Systems, Inc.  All rights reserved.
3 #
4 #    Licensed under the Apache License, Version 2.0 (the "License"); you may
5 #    not use this file except in compliance with the License. You may obtain
6 #    a copy of the License at
7 #
8 #         http://www.apache.org/licenses/LICENSE-2.0
9 #
10 #    Unless required by applicable law or agreed to in writing, software
11 #    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 #    License for the specific language governing permissions and limitations
14 #    under the License.
15 #
16
17 import argparse
18 from collections import defaultdict
19 import copy
20 import datetime
21 import importlib
22 import json
23 import os
24 import sys
25 import traceback
26
27 from attrdict import AttrDict
28 import pbr.version
29 from pkg_resources import resource_string
30
31 from __init__ import __version__
32 from chain_runner import ChainRunner
33 from cleanup import Cleaner
34 from config import config_load
35 from config import config_loads
36 import credentials as credentials
37 from factory import BasicFactory
38 from fluentd import FluentLogHandler
39 import log
40 from log import LOG
41 from nfvbenchd import WebSocketIoServer
42 from specs import ChainType
43 from specs import Specs
44 from summarizer import NFVBenchSummarizer
45 from traffic_client import TrafficGeneratorFactory
46 import utils
47
48 fluent_logger = None
49
50
51 class NFVBench(object):
52     """Main class of NFV benchmarking tool."""
53
54     STATUS_OK = 'OK'
55     STATUS_ERROR = 'ERROR'
56
57     def __init__(self, config, openstack_spec, config_plugin, factory, notifier=None):
58         self.base_config = config
59         self.config = None
60         self.config_plugin = config_plugin
61         self.factory = factory
62         self.notifier = notifier
63         self.cred = credentials.Credentials(config.openrc_file, None, False) \
64             if config.openrc_file else None
65         self.chain_runner = None
66         self.specs = Specs()
67         self.specs.set_openstack_spec(openstack_spec)
68         self.clients = defaultdict(lambda: None)
69         self.vni_ports = []
70         sys.stdout.flush()
71
72     def setup(self):
73         self.specs.set_run_spec(self.config_plugin.get_run_spec(self.config, self.specs.openstack))
74         self.chain_runner = ChainRunner(self.config,
75                                         self.clients,
76                                         self.cred,
77                                         self.specs,
78                                         self.factory,
79                                         self.notifier)
80
81     def set_notifier(self, notifier):
82         self.notifier = notifier
83
84     def run(self, opts, args):
85         status = NFVBench.STATUS_OK
86         result = None
87         message = ''
88         if fluent_logger:
89             # take a snapshot of the current time for this new run
90             # so that all subsequent logs can relate to this run
91             fluent_logger.start_new_run()
92         LOG.info(args)
93         try:
94             self.update_config(opts)
95             self.setup()
96             new_frame_sizes = []
97             min_packet_size = "68" if self.config.vlan_tagging else "64"
98             for frame_size in self.config.frame_sizes:
99                 try:
100                     if int(frame_size) < int(min_packet_size):
101                         new_frame_sizes.append(min_packet_size)
102                         LOG.info("Adjusting frame size %s Bytes to minimum size %s Bytes due to " +
103                                  "traffic generator restriction", frame_size, min_packet_size)
104                     else:
105                         new_frame_sizes.append(frame_size)
106                 except ValueError:
107                     new_frame_sizes.append(frame_size)
108             self.config.actual_frame_sizes = tuple(new_frame_sizes)
109             result = {
110                 "date": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
111                 "nfvbench_version": __version__,
112                 "config": self.config_plugin.prepare_results_config(copy.deepcopy(self.config)),
113                 "benchmarks": {
114                     "network": {
115                         "service_chain": self.chain_runner.run(),
116                         "versions": self.chain_runner.get_version(),
117                     }
118                 }
119             }
120             if self.specs.openstack:
121                 result['openstack_spec'] = {"vswitch": self.specs.openstack.vswitch,
122                                             "encaps": self.specs.openstack.encaps}
123             result['benchmarks']['network']['versions'].update(self.config_plugin.get_version())
124         except Exception:
125             status = NFVBench.STATUS_ERROR
126             message = traceback.format_exc()
127         except KeyboardInterrupt:
128             status = NFVBench.STATUS_ERROR
129             message = traceback.format_exc()
130         finally:
131             if self.chain_runner:
132                 self.chain_runner.close()
133
134         if status == NFVBench.STATUS_OK:
135             result = utils.dict_to_json_dict(result)
136             return {
137                 'status': status,
138                 'result': result
139             }
140         return {
141             'status': status,
142             'error_message': message
143         }
144
145     def prepare_summary(self, result):
146         """Prepare summary of the result to print and send it to logger (eg: fluentd)."""
147         global fluent_logger
148         summary = NFVBenchSummarizer(result, fluent_logger)
149         LOG.info(str(summary))
150
151     def save(self, result):
152         """Save results in json format file."""
153         utils.save_json_result(result,
154                                self.config.json_file,
155                                self.config.std_json_path,
156                                self.config.service_chain,
157                                self.config.service_chain_count,
158                                self.config.flow_count,
159                                self.config.frame_sizes)
160
161     def update_config(self, opts):
162         self.config = AttrDict(dict(self.base_config))
163         self.config.update(opts)
164
165         self.config.service_chain = self.config.service_chain.upper()
166         self.config.service_chain_count = int(self.config.service_chain_count)
167         self.config.flow_count = utils.parse_flow_count(self.config.flow_count)
168         required_flow_count = self.config.service_chain_count * 2
169         if self.config.flow_count < required_flow_count:
170             LOG.info("Flow count %d has been set to minimum value of '%d' "
171                      "for current configuration", self.config.flow_count,
172                      required_flow_count)
173             self.config.flow_count = required_flow_count
174
175         if self.config.flow_count % 2 != 0:
176             self.config.flow_count += 1
177
178         self.config.duration_sec = float(self.config.duration_sec)
179         self.config.interval_sec = float(self.config.interval_sec)
180         self.config.pause_sec = float(self.config.pause_sec)
181
182         # Get traffic generator profile config
183         if not self.config.generator_profile:
184             self.config.generator_profile = self.config.traffic_generator.default_profile
185
186         generator_factory = TrafficGeneratorFactory(self.config)
187         self.config.generator_config = \
188             generator_factory.get_generator_config(self.config.generator_profile)
189
190         # Check length of mac_addrs_left/right for serivce_chain EXT with no_arp
191         if self.config.service_chain == ChainType.EXT and self.config.no_arp:
192             if not (self.config.generator_config.mac_addrs_left is None and
193                     self.config.generator_config.mac_addrs_right is None):
194                 if (self.config.generator_config.mac_addrs_left is None or
195                         self.config.generator_config.mac_addrs_right is None):
196                     raise Exception("mac_addrs_left and mac_addrs_right must either "
197                                     "both be None or have a number of entries matching "
198                                     "service_chain_count")
199                 if not (len(self.config.generator_config.mac_addrs_left) ==
200                         self.config.service_chain_count and
201                         len(self.config.generator_config.mac_addrs_right) ==
202                         self.config.service_chain_count):
203                     raise Exception("length of mac_addrs_left ({a}) and/or mac_addrs_right ({b}) "
204                                     "does not match service_chain_count ({c})"
205                                     .format(a=len(self.config.generator_config.mac_addrs_left),
206                                             b=len(self.config.generator_config.mac_addrs_right),
207                                             c=self.config.service_chain_count))
208
209         if not any(self.config.generator_config.pcis):
210             raise Exception("PCI addresses configuration for selected traffic generator profile "
211                             "({tg_profile}) are missing. Please specify them in configuration file."
212                             .format(tg_profile=self.config.generator_profile))
213
214         if self.config.traffic is None or not self.config.traffic:
215             raise Exception("No traffic profile found in traffic configuration, "
216                             "please fill 'traffic' section in configuration file.")
217
218         if isinstance(self.config.traffic, tuple):
219             self.config.traffic = self.config.traffic[0]
220
221         self.config.frame_sizes = generator_factory.get_frame_sizes(self.config.traffic.profile)
222
223         self.config.ipv6_mode = False
224         self.config.no_dhcp = True
225         self.config.same_network_only = True
226         if self.config.openrc_file:
227             self.config.openrc_file = os.path.expanduser(self.config.openrc_file)
228
229         self.config.ndr_run = (not self.config.no_traffic and
230                                'ndr' in self.config.rate.strip().lower().split('_'))
231         self.config.pdr_run = (not self.config.no_traffic and
232                                'pdr' in self.config.rate.strip().lower().split('_'))
233         self.config.single_run = (not self.config.no_traffic and
234                                   not (self.config.ndr_run or self.config.pdr_run))
235
236         if self.config.vlans and len(self.config.vlans) != 2:
237             raise Exception('Number of configured VLAN IDs for VLAN tagging must be exactly 2.')
238
239         self.config.json_file = self.config.json if self.config.json else None
240         if self.config.json_file:
241             (path, _filename) = os.path.split(self.config.json)
242             if not os.path.exists(path):
243                 raise Exception('Please provide existing path for storing results in JSON file. '
244                                 'Path used: {path}'.format(path=path))
245
246         self.config.std_json_path = self.config.std_json if self.config.std_json else None
247         if self.config.std_json_path:
248             if not os.path.exists(self.config.std_json):
249                 raise Exception('Please provide existing path for storing results in JSON file. '
250                                 'Path used: {path}'.format(path=self.config.std_json_path))
251
252         self.config_plugin.validate_config(self.config, self.specs.openstack)
253
254
255 def parse_opts_from_cli():
256     parser = argparse.ArgumentParser()
257
258     parser.add_argument('--status', dest='status',
259                         action='store_true',
260                         default=None,
261                         help='Provide NFVbench status')
262
263     parser.add_argument('-c', '--config', dest='config',
264                         action='store',
265                         help='Override default values with a config file or '
266                              'a yaml/json config string',
267                         metavar='<file_name_or_yaml>')
268
269     parser.add_argument('--server', dest='server',
270                         default=None,
271                         action='store',
272                         metavar='<http_root_pathname>',
273                         help='Run nfvbench in server mode and pass'
274                              ' the HTTP root folder full pathname')
275
276     parser.add_argument('--host', dest='host',
277                         action='store',
278                         default='0.0.0.0',
279                         help='Host IP address on which server will be listening (default 0.0.0.0)')
280
281     parser.add_argument('-p', '--port', dest='port',
282                         action='store',
283                         default=7555,
284                         help='Port on which server will be listening (default 7555)')
285
286     parser.add_argument('-sc', '--service-chain', dest='service_chain',
287                         choices=BasicFactory.chain_classes,
288                         action='store',
289                         help='Service chain to run')
290
291     parser.add_argument('-scc', '--service-chain-count', dest='service_chain_count',
292                         action='store',
293                         help='Set number of service chains to run',
294                         metavar='<service_chain_count>')
295
296     parser.add_argument('-fc', '--flow-count', dest='flow_count',
297                         action='store',
298                         help='Set number of total flows for all chains and all directions',
299                         metavar='<flow_count>')
300
301     parser.add_argument('--rate', dest='rate',
302                         action='store',
303                         help='Specify rate in pps, bps or %% as total for all directions',
304                         metavar='<rate>')
305
306     parser.add_argument('--duration', dest='duration_sec',
307                         action='store',
308                         help='Set duration to run traffic generator (in seconds)',
309                         metavar='<duration_sec>')
310
311     parser.add_argument('--interval', dest='interval_sec',
312                         action='store',
313                         help='Set interval to record traffic generator stats (in seconds)',
314                         metavar='<interval_sec>')
315
316     parser.add_argument('--inter-node', dest='inter_node',
317                         default=None,
318                         action='store_true',
319                         help='run VMs in different compute nodes (PVVP only)')
320
321     parser.add_argument('--sriov', dest='sriov',
322                         default=None,
323                         action='store_true',
324                         help='Use SRIOV (no vswitch - requires SRIOV support in compute nodes)')
325
326     parser.add_argument('--use-sriov-middle-net', dest='use_sriov_middle_net',
327                         default=None,
328                         action='store_true',
329                         help='Use SRIOV to handle the middle network traffic '
330                              '(PVVP with SRIOV only)')
331
332     parser.add_argument('-d', '--debug', dest='debug',
333                         action='store_true',
334                         default=None,
335                         help='print debug messages (verbose)')
336
337     parser.add_argument('-g', '--traffic-gen', dest='generator_profile',
338                         action='store',
339                         help='Traffic generator profile to use')
340
341     parser.add_argument('-0', '--no-traffic', dest='no_traffic',
342                         default=None,
343                         action='store_true',
344                         help='Check config and connectivity only - do not generate traffic')
345
346     parser.add_argument('--no-arp', dest='no_arp',
347                         default=None,
348                         action='store_true',
349                         help='Do not use ARP to find MAC addresses, '
350                              'instead use values in config file')
351
352     parser.add_argument('--no-reset', dest='no_reset',
353                         default=None,
354                         action='store_true',
355                         help='Do not reset counters prior to running')
356
357     parser.add_argument('--no-int-config', dest='no_int_config',
358                         default=None,
359                         action='store_true',
360                         help='Skip interfaces config on EXT service chain')
361
362     parser.add_argument('--no-tor-access', dest='no_tor_access',
363                         default=None,
364                         action='store_true',
365                         help='Skip TOR switch configuration and retrieving of stats')
366
367     parser.add_argument('--no-vswitch-access', dest='no_vswitch_access',
368                         default=None,
369                         action='store_true',
370                         help='Skip vswitch configuration and retrieving of stats')
371
372     parser.add_argument('--no-cleanup', dest='no_cleanup',
373                         default=None,
374                         action='store_true',
375                         help='no cleanup after run')
376
377     parser.add_argument('--cleanup', dest='cleanup',
378                         default=None,
379                         action='store_true',
380                         help='Cleanup NFVbench resources (prompt to confirm)')
381
382     parser.add_argument('--force-cleanup', dest='force_cleanup',
383                         default=None,
384                         action='store_true',
385                         help='Cleanup NFVbench resources (do not prompt)')
386
387     parser.add_argument('--json', dest='json',
388                         action='store',
389                         help='store results in json format file',
390                         metavar='<path>/<filename>')
391
392     parser.add_argument('--std-json', dest='std_json',
393                         action='store',
394                         help='store results in json format file with nfvbench standard filename: '
395                              '<service-chain-type>-<service-chain-count>-<flow-count>'
396                              '-<packet-sizes>.json',
397                         metavar='<path>')
398
399     parser.add_argument('--show-default-config', dest='show_default_config',
400                         default=None,
401                         action='store_true',
402                         help='print the default config in yaml format (unedited)')
403
404     parser.add_argument('--show-config', dest='show_config',
405                         default=None,
406                         action='store_true',
407                         help='print the running config in json format')
408
409     parser.add_argument('-ss', '--show-summary', dest='summary',
410                         action='store',
411                         help='Show summary from nfvbench json file',
412                         metavar='<json>')
413
414     parser.add_argument('-v', '--version', dest='version',
415                         default=None,
416                         action='store_true',
417                         help='Show version')
418
419     parser.add_argument('-fs', '--frame-size', dest='frame_sizes',
420                         action='append',
421                         help='Override traffic profile frame sizes',
422                         metavar='<frame_size_bytes or IMIX>')
423
424     parser.add_argument('--unidir', dest='unidir',
425                         action='store_true',
426                         default=None,
427                         help='Override traffic profile direction (requires -fs)')
428
429     parser.add_argument('--log-file', '--logfile', dest='log_file',
430                         action='store',
431                         help='Filename for saving logs',
432                         metavar='<log_file>')
433
434     parser.add_argument('--user-label', '--userlabel', dest='user_label',
435                         action='store',
436                         help='Custom label for performance records')
437
438     parser.add_argument('--l2-loopback', '--l2loopback', dest='l2_loopback',
439                         action='store',
440                         metavar='<vlan>',
441                         help='Port to port or port to switch to port L2 loopback with VLAN id')
442
443     opts, unknown_opts = parser.parse_known_args()
444     return opts, unknown_opts
445
446
447 def load_default_config():
448     default_cfg = resource_string(__name__, "cfg.default.yaml")
449     config = config_loads(default_cfg)
450     config.name = '(built-in default config)'
451     return config, default_cfg
452
453
454 def override_custom_traffic(config, frame_sizes, unidir):
455     """Override the traffic profiles with a custom one."""
456     if frame_sizes is not None:
457         traffic_profile_name = "custom_traffic_profile"
458         config.traffic_profile = [
459             {
460                 "l2frame_size": frame_sizes,
461                 "name": traffic_profile_name
462             }
463         ]
464     else:
465         traffic_profile_name = config.traffic["profile"]
466
467     bidirectional = config.traffic['bidirectional'] if unidir is None else not unidir
468     config.traffic = {
469         "bidirectional": bidirectional,
470         "profile": traffic_profile_name
471     }
472
473
474 def check_physnet(name, netattrs):
475     if not netattrs.physical_network:
476         raise Exception("SRIOV requires physical_network to be specified for the {n} network"
477                         .format(n=name))
478     if not netattrs.segmentation_id:
479         raise Exception("SRIOV requires segmentation_id to be specified for the {n} network"
480                         .format(n=name))
481
482 def status_cleanup(config, cleanup, force_cleanup):
483     LOG.info('Version: %s', pbr.version.VersionInfo('nfvbench').version_string_with_vcs())
484     # check if another run is pending
485     ret_code = 0
486     try:
487         with utils.RunLock():
488             LOG.info('Status: idle')
489     except Exception:
490         LOG.info('Status: busy (run pending)')
491         ret_code = 1
492     # check nfvbench resources
493     if config.openrc_file and config.service_chain != ChainType.EXT:
494         cleaner = Cleaner(config)
495         count = cleaner.show_resources()
496         if count and (cleanup or force_cleanup):
497             cleaner.clean(not force_cleanup)
498     sys.exit(ret_code)
499
500 def main():
501     global fluent_logger
502     run_summary_required = False
503     try:
504         log.setup()
505         # load default config file
506         config, default_cfg = load_default_config()
507         # create factory for platform specific classes
508         try:
509             factory_module = importlib.import_module(config['factory_module'])
510             factory = getattr(factory_module, config['factory_class'])()
511         except AttributeError:
512             raise Exception("Requested factory module '{m}' or class '{c}' was not found."
513                             .format(m=config['factory_module'], c=config['factory_class']))
514         # create config plugin for this platform
515         config_plugin = factory.get_config_plugin_class()(config)
516         config = config_plugin.get_config()
517
518         opts, unknown_opts = parse_opts_from_cli()
519         log.set_level(debug=opts.debug)
520
521         if opts.version:
522             print pbr.version.VersionInfo('nfvbench').version_string_with_vcs()
523             sys.exit(0)
524
525         if opts.summary:
526             with open(opts.summary) as json_data:
527                 result = json.load(json_data)
528                 if opts.user_label:
529                     result['config']['user_label'] = opts.user_label
530                 print NFVBenchSummarizer(result, fluent_logger)
531             sys.exit(0)
532
533         # show default config in text/yaml format
534         if opts.show_default_config:
535             print default_cfg
536             sys.exit(0)
537
538         config.name = ''
539         if opts.config:
540             # do not check extra_specs in flavor as it can contain any key/value pairs
541             whitelist_keys = ['extra_specs']
542             # override default config options with start config at path parsed from CLI
543             # check if it is an inline yaml/json config or a file name
544             if os.path.isfile(opts.config):
545                 LOG.info('Loading configuration file: %s', opts.config)
546                 config = config_load(opts.config, config, whitelist_keys)
547                 config.name = os.path.basename(opts.config)
548             else:
549                 LOG.info('Loading configuration string: %s', opts.config)
550                 config = config_loads(opts.config, config, whitelist_keys)
551
552         # setup the fluent logger as soon as possible right after the config plugin is called,
553         # if there is any logging or result tag is set then initialize the fluent logger
554         for fluentd in config.fluentd:
555             if fluentd.logging_tag or fluentd.result_tag:
556                 fluent_logger = FluentLogHandler(config.fluentd)
557                 LOG.addHandler(fluent_logger)
558                 break
559
560         # traffic profile override options
561         override_custom_traffic(config, opts.frame_sizes, opts.unidir)
562
563         # copy over cli options that are used in config
564         config.generator_profile = opts.generator_profile
565         if opts.sriov:
566             config.sriov = True
567         if opts.log_file:
568             config.log_file = opts.log_file
569         if opts.service_chain:
570             config.service_chain = opts.service_chain
571         if opts.service_chain_count:
572             config.service_chain_count = opts.service_chain_count
573         if opts.no_vswitch_access:
574             config.no_vswitch_access = opts.no_vswitch_access
575         if opts.no_int_config:
576             config.no_int_config = opts.no_int_config
577
578         # port to port loopback (direct or through switch)
579         if opts.l2_loopback:
580             config.l2_loopback = True
581             if config.service_chain != ChainType.EXT:
582                 LOG.info('Changing service chain type to EXT')
583                 config.service_chain = ChainType.EXT
584             if not config.no_arp:
585                 LOG.info('Disabling ARP')
586                 config.no_arp = True
587             config.vlans = [int(opts.l2_loopback), int(opts.l2_loopback)]
588             # disable any form of interface config since we loop at the switch level
589             config.no_int_config = True
590             LOG.info('Running L2 loopback: using EXT chain/no ARP')
591
592         if opts.use_sriov_middle_net:
593             if (not config.sriov) or (config.service_chain != ChainType.PVVP):
594                 raise Exception("--use-sriov-middle-net is only valid for PVVP with SRIOV")
595             config.use_sriov_middle_net = True
596
597         if config.sriov and config.service_chain != ChainType.EXT:
598             # if sriov is requested (does not apply to ext chains)
599             # make sure the physnet names are specified
600             check_physnet("left", config.internal_networks.left)
601             check_physnet("right", config.internal_networks.right)
602             if config.service_chain == ChainType.PVVP and config.use_sriov_middle_net:
603                 check_physnet("middle", config.internal_networks.middle)
604
605         # show running config in json format
606         if opts.show_config:
607             print json.dumps(config, sort_keys=True, indent=4)
608             sys.exit(0)
609
610         # check that an empty openrc file (no OpenStack) is only allowed
611         # with EXT chain
612         if not config.openrc_file:
613             if config.service_chain == ChainType.EXT:
614                 LOG.info('EXT chain with OpenStack mode disabled')
615             else:
616                 raise Exception("openrc_file is empty in the configuration and is required")
617
618         # update the config in the config plugin as it might have changed
619         # in a copy of the dict (config plugin still holds the original dict)
620         config_plugin.set_config(config)
621
622         if opts.status or opts.cleanup or opts.force_cleanup:
623             status_cleanup(config, opts.cleanup, opts.force_cleanup)
624
625         # add file log if requested
626         if config.log_file:
627             log.add_file_logger(config.log_file)
628
629         openstack_spec = config_plugin.get_openstack_spec() if config.openrc_file \
630             else None
631
632         nfvbench_instance = NFVBench(config, openstack_spec, config_plugin, factory)
633
634         if opts.server:
635             if os.path.isdir(opts.server):
636                 server = WebSocketIoServer(opts.server, nfvbench_instance, fluent_logger)
637                 nfvbench_instance.set_notifier(server)
638                 try:
639                     port = int(opts.port)
640                 except ValueError:
641                     server.run(host=opts.host)
642                 else:
643                     server.run(host=opts.host, port=port)
644             else:
645                 print 'Invalid HTTP root directory: ' + opts.server
646                 sys.exit(1)
647         else:
648             with utils.RunLock():
649                 run_summary_required = True
650                 if unknown_opts:
651                     err_msg = 'Unknown options: ' + ' '.join(unknown_opts)
652                     LOG.error(err_msg)
653                     raise Exception(err_msg)
654
655                 # remove unfilled values
656                 opts = {k: v for k, v in vars(opts).iteritems() if v is not None}
657                 # get CLI args
658                 params = ' '.join(str(e) for e in sys.argv[1:])
659                 result = nfvbench_instance.run(opts, params)
660                 if 'error_message' in result:
661                     raise Exception(result['error_message'])
662
663                 if 'result' in result and result['status']:
664                     nfvbench_instance.save(result['result'])
665                     nfvbench_instance.prepare_summary(result['result'])
666     except Exception as exc:
667         run_summary_required = True
668         LOG.error({
669             'status': NFVBench.STATUS_ERROR,
670             'error_message': traceback.format_exc()
671         })
672         print str(exc)
673     finally:
674         if fluent_logger:
675             # only send a summary record if there was an actual nfvbench run or
676             # if an error/exception was logged.
677             fluent_logger.send_run_summary(run_summary_required)
678
679
680 if __name__ == '__main__':
681     main()