NFVBENCH-94 End to end traffic test triggers too early on chatty network
[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
181         # Get traffic generator profile config
182         if not self.config.generator_profile:
183             self.config.generator_profile = self.config.traffic_generator.default_profile
184
185         generator_factory = TrafficGeneratorFactory(self.config)
186         self.config.generator_config = \
187             generator_factory.get_generator_config(self.config.generator_profile)
188
189         # Check length of mac_addrs_left/right for serivce_chain EXT with no_arp
190         if self.config.service_chain == ChainType.EXT and self.config.no_arp:
191             if not (self.config.generator_config.mac_addrs_left is None and
192                     self.config.generator_config.mac_addrs_right is None):
193                 if (self.config.generator_config.mac_addrs_left is None or
194                         self.config.generator_config.mac_addrs_right is None):
195                     raise Exception("mac_addrs_left and mac_addrs_right must either "
196                                     "both be None or have a number of entries matching "
197                                     "service_chain_count")
198                 if not (len(self.config.generator_config.mac_addrs_left) ==
199                         self.config.service_chain_count and
200                         len(self.config.generator_config.mac_addrs_right) ==
201                         self.config.service_chain_count):
202                     raise Exception("length of mac_addrs_left ({a}) and/or mac_addrs_right ({b}) "
203                                     "does not match service_chain_count ({c})"
204                                     .format(a=len(self.config.generator_config.mac_addrs_left),
205                                             b=len(self.config.generator_config.mac_addrs_right),
206                                             c=self.config.service_chain_count))
207
208         if not any(self.config.generator_config.pcis):
209             raise Exception("PCI addresses configuration for selected traffic generator profile "
210                             "({tg_profile}) are missing. Please specify them in configuration file."
211                             .format(tg_profile=self.config.generator_profile))
212
213         if self.config.traffic is None or not self.config.traffic:
214             raise Exception("No traffic profile found in traffic configuration, "
215                             "please fill 'traffic' section in configuration file.")
216
217         if isinstance(self.config.traffic, tuple):
218             self.config.traffic = self.config.traffic[0]
219
220         self.config.frame_sizes = generator_factory.get_frame_sizes(self.config.traffic.profile)
221
222         self.config.ipv6_mode = False
223         self.config.no_dhcp = True
224         self.config.same_network_only = True
225         if self.config.openrc_file:
226             self.config.openrc_file = os.path.expanduser(self.config.openrc_file)
227
228         self.config.ndr_run = (not self.config.no_traffic and
229                                'ndr' in self.config.rate.strip().lower().split('_'))
230         self.config.pdr_run = (not self.config.no_traffic and
231                                'pdr' in self.config.rate.strip().lower().split('_'))
232         self.config.single_run = (not self.config.no_traffic and
233                                   not (self.config.ndr_run or self.config.pdr_run))
234
235         if self.config.vlans and len(self.config.vlans) != 2:
236             raise Exception('Number of configured VLAN IDs for VLAN tagging must be exactly 2.')
237
238         self.config.json_file = self.config.json if self.config.json else None
239         if self.config.json_file:
240             (path, _filename) = os.path.split(self.config.json)
241             if not os.path.exists(path):
242                 raise Exception('Please provide existing path for storing results in JSON file. '
243                                 'Path used: {path}'.format(path=path))
244
245         self.config.std_json_path = self.config.std_json if self.config.std_json else None
246         if self.config.std_json_path:
247             if not os.path.exists(self.config.std_json):
248                 raise Exception('Please provide existing path for storing results in JSON file. '
249                                 'Path used: {path}'.format(path=self.config.std_json_path))
250
251         self.config_plugin.validate_config(self.config, self.specs.openstack)
252
253
254 def parse_opts_from_cli():
255     parser = argparse.ArgumentParser()
256
257     parser.add_argument('--status', dest='status',
258                         action='store_true',
259                         default=None,
260                         help='Provide NFVbench status')
261
262     parser.add_argument('-c', '--config', dest='config',
263                         action='store',
264                         help='Override default values with a config file or '
265                              'a yaml/json config string',
266                         metavar='<file_name_or_yaml>')
267
268     parser.add_argument('--server', dest='server',
269                         default=None,
270                         action='store',
271                         metavar='<http_root_pathname>',
272                         help='Run nfvbench in server mode and pass'
273                              ' the HTTP root folder full pathname')
274
275     parser.add_argument('--host', dest='host',
276                         action='store',
277                         default='0.0.0.0',
278                         help='Host IP address on which server will be listening (default 0.0.0.0)')
279
280     parser.add_argument('-p', '--port', dest='port',
281                         action='store',
282                         default=7555,
283                         help='Port on which server will be listening (default 7555)')
284
285     parser.add_argument('-sc', '--service-chain', dest='service_chain',
286                         choices=BasicFactory.chain_classes,
287                         action='store',
288                         help='Service chain to run')
289
290     parser.add_argument('-scc', '--service-chain-count', dest='service_chain_count',
291                         action='store',
292                         help='Set number of service chains to run',
293                         metavar='<service_chain_count>')
294
295     parser.add_argument('-fc', '--flow-count', dest='flow_count',
296                         action='store',
297                         help='Set number of total flows for all chains and all directions',
298                         metavar='<flow_count>')
299
300     parser.add_argument('--rate', dest='rate',
301                         action='store',
302                         help='Specify rate in pps, bps or %% as total for all directions',
303                         metavar='<rate>')
304
305     parser.add_argument('--duration', dest='duration_sec',
306                         action='store',
307                         help='Set duration to run traffic generator (in seconds)',
308                         metavar='<duration_sec>')
309
310     parser.add_argument('--interval', dest='interval_sec',
311                         action='store',
312                         help='Set interval to record traffic generator stats (in seconds)',
313                         metavar='<interval_sec>')
314
315     parser.add_argument('--inter-node', dest='inter_node',
316                         default=None,
317                         action='store_true',
318                         help='run VMs in different compute nodes (PVVP only)')
319
320     parser.add_argument('--sriov', dest='sriov',
321                         default=None,
322                         action='store_true',
323                         help='Use SRIOV (no vswitch - requires SRIOV support in compute nodes)')
324
325     parser.add_argument('--use-sriov-middle-net', dest='use_sriov_middle_net',
326                         default=None,
327                         action='store_true',
328                         help='Use SRIOV to handle the middle network traffic '
329                              '(PVVP with SRIOV only)')
330
331     parser.add_argument('-d', '--debug', dest='debug',
332                         action='store_true',
333                         default=None,
334                         help='print debug messages (verbose)')
335
336     parser.add_argument('-g', '--traffic-gen', dest='generator_profile',
337                         action='store',
338                         help='Traffic generator profile to use')
339
340     parser.add_argument('-0', '--no-traffic', dest='no_traffic',
341                         default=None,
342                         action='store_true',
343                         help='Check config and connectivity only - do not generate traffic')
344
345     parser.add_argument('--no-arp', dest='no_arp',
346                         default=None,
347                         action='store_true',
348                         help='Do not use ARP to find MAC addresses, '
349                              'instead use values in config file')
350
351     parser.add_argument('--no-reset', dest='no_reset',
352                         default=None,
353                         action='store_true',
354                         help='Do not reset counters prior to running')
355
356     parser.add_argument('--no-int-config', dest='no_int_config',
357                         default=None,
358                         action='store_true',
359                         help='Skip interfaces config on EXT service chain')
360
361     parser.add_argument('--no-tor-access', dest='no_tor_access',
362                         default=None,
363                         action='store_true',
364                         help='Skip TOR switch configuration and retrieving of stats')
365
366     parser.add_argument('--no-vswitch-access', dest='no_vswitch_access',
367                         default=None,
368                         action='store_true',
369                         help='Skip vswitch configuration and retrieving of stats')
370
371     parser.add_argument('--no-cleanup', dest='no_cleanup',
372                         default=None,
373                         action='store_true',
374                         help='no cleanup after run')
375
376     parser.add_argument('--cleanup', dest='cleanup',
377                         default=None,
378                         action='store_true',
379                         help='Cleanup NFVbench resources (prompt to confirm)')
380
381     parser.add_argument('--force-cleanup', dest='force_cleanup',
382                         default=None,
383                         action='store_true',
384                         help='Cleanup NFVbench resources (do not prompt)')
385
386     parser.add_argument('--json', dest='json',
387                         action='store',
388                         help='store results in json format file',
389                         metavar='<path>/<filename>')
390
391     parser.add_argument('--std-json', dest='std_json',
392                         action='store',
393                         help='store results in json format file with nfvbench standard filename: '
394                              '<service-chain-type>-<service-chain-count>-<flow-count>'
395                              '-<packet-sizes>.json',
396                         metavar='<path>')
397
398     parser.add_argument('--show-default-config', dest='show_default_config',
399                         default=None,
400                         action='store_true',
401                         help='print the default config in yaml format (unedited)')
402
403     parser.add_argument('--show-config', dest='show_config',
404                         default=None,
405                         action='store_true',
406                         help='print the running config in json format')
407
408     parser.add_argument('-ss', '--show-summary', dest='summary',
409                         action='store',
410                         help='Show summary from nfvbench json file',
411                         metavar='<json>')
412
413     parser.add_argument('-v', '--version', dest='version',
414                         default=None,
415                         action='store_true',
416                         help='Show version')
417
418     parser.add_argument('-fs', '--frame-size', dest='frame_sizes',
419                         action='append',
420                         help='Override traffic profile frame sizes',
421                         metavar='<frame_size_bytes or IMIX>')
422
423     parser.add_argument('--unidir', dest='unidir',
424                         action='store_true',
425                         default=None,
426                         help='Override traffic profile direction (requires -fs)')
427
428     parser.add_argument('--log-file', '--logfile', dest='log_file',
429                         action='store',
430                         help='Filename for saving logs',
431                         metavar='<log_file>')
432
433     parser.add_argument('--user-label', '--userlabel', dest='user_label',
434                         action='store',
435                         help='Custom label for performance records')
436
437     parser.add_argument('--l2-loopback', '--l2loopback', dest='l2_loopback',
438                         action='store',
439                         metavar='<vlan>',
440                         help='Port to port or port to switch to port L2 loopback with VLAN id')
441
442     opts, unknown_opts = parser.parse_known_args()
443     return opts, unknown_opts
444
445
446 def load_default_config():
447     default_cfg = resource_string(__name__, "cfg.default.yaml")
448     config = config_loads(default_cfg)
449     config.name = '(built-in default config)'
450     return config, default_cfg
451
452
453 def override_custom_traffic(config, frame_sizes, unidir):
454     """Override the traffic profiles with a custom one."""
455     if frame_sizes is not None:
456         traffic_profile_name = "custom_traffic_profile"
457         config.traffic_profile = [
458             {
459                 "l2frame_size": frame_sizes,
460                 "name": traffic_profile_name
461             }
462         ]
463     else:
464         traffic_profile_name = config.traffic["profile"]
465
466     bidirectional = config.traffic['bidirectional'] if unidir is None else not unidir
467     config.traffic = {
468         "bidirectional": bidirectional,
469         "profile": traffic_profile_name
470     }
471
472
473 def check_physnet(name, netattrs):
474     if not netattrs.physical_network:
475         raise Exception("SRIOV requires physical_network to be specified for the {n} network"
476                         .format(n=name))
477     if not netattrs.segmentation_id:
478         raise Exception("SRIOV requires segmentation_id to be specified for the {n} network"
479                         .format(n=name))
480
481 def status_cleanup(config, cleanup, force_cleanup):
482     LOG.info('Version: %s', pbr.version.VersionInfo('nfvbench').version_string_with_vcs())
483     # check if another run is pending
484     ret_code = 0
485     try:
486         with utils.RunLock():
487             LOG.info('Status: idle')
488     except Exception:
489         LOG.info('Status: busy (run pending)')
490         ret_code = 1
491     # check nfvbench resources
492     if config.openrc_file and config.service_chain != ChainType.EXT:
493         cleaner = Cleaner(config)
494         count = cleaner.show_resources()
495         if count and (cleanup or force_cleanup):
496             cleaner.clean(not force_cleanup)
497     sys.exit(ret_code)
498
499 def main():
500     global fluent_logger
501     run_summary_required = False
502     try:
503         log.setup()
504         # load default config file
505         config, default_cfg = load_default_config()
506         # create factory for platform specific classes
507         try:
508             factory_module = importlib.import_module(config['factory_module'])
509             factory = getattr(factory_module, config['factory_class'])()
510         except AttributeError:
511             raise Exception("Requested factory module '{m}' or class '{c}' was not found."
512                             .format(m=config['factory_module'], c=config['factory_class']))
513         # create config plugin for this platform
514         config_plugin = factory.get_config_plugin_class()(config)
515         config = config_plugin.get_config()
516
517         opts, unknown_opts = parse_opts_from_cli()
518         log.set_level(debug=opts.debug)
519
520         if opts.version:
521             print pbr.version.VersionInfo('nfvbench').version_string_with_vcs()
522             sys.exit(0)
523
524         if opts.summary:
525             with open(opts.summary) as json_data:
526                 result = json.load(json_data)
527                 if opts.user_label:
528                     result['config']['user_label'] = opts.user_label
529                 print NFVBenchSummarizer(result, fluent_logger)
530             sys.exit(0)
531
532         # show default config in text/yaml format
533         if opts.show_default_config:
534             print default_cfg
535             sys.exit(0)
536
537         config.name = ''
538         if opts.config:
539             # do not check extra_specs in flavor as it can contain any key/value pairs
540             whitelist_keys = ['extra_specs']
541             # override default config options with start config at path parsed from CLI
542             # check if it is an inline yaml/json config or a file name
543             if os.path.isfile(opts.config):
544                 LOG.info('Loading configuration file: %s', opts.config)
545                 config = config_load(opts.config, config, whitelist_keys)
546                 config.name = os.path.basename(opts.config)
547             else:
548                 LOG.info('Loading configuration string: %s', opts.config)
549                 config = config_loads(opts.config, config, whitelist_keys)
550
551         # setup the fluent logger as soon as possible right after the config plugin is called,
552         # if there is any logging or result tag is set then initialize the fluent logger
553         for fluentd in config.fluentd:
554             if fluentd.logging_tag or fluentd.result_tag:
555                 fluent_logger = FluentLogHandler(config.fluentd)
556                 LOG.addHandler(fluent_logger)
557                 break
558
559         # traffic profile override options
560         override_custom_traffic(config, opts.frame_sizes, opts.unidir)
561
562         # copy over cli options that are used in config
563         config.generator_profile = opts.generator_profile
564         if opts.sriov:
565             config.sriov = True
566         if opts.log_file:
567             config.log_file = opts.log_file
568         if opts.service_chain:
569             config.service_chain = opts.service_chain
570         if opts.service_chain_count:
571             config.service_chain_count = opts.service_chain_count
572         if opts.no_vswitch_access:
573             config.no_vswitch_access = opts.no_vswitch_access
574         if opts.no_int_config:
575             config.no_int_config = opts.no_int_config
576
577         # port to port loopback (direct or through switch)
578         if opts.l2_loopback:
579             config.l2_loopback = True
580             if config.service_chain != ChainType.EXT:
581                 LOG.info('Changing service chain type to EXT')
582                 config.service_chain = ChainType.EXT
583             if not config.no_arp:
584                 LOG.info('Disabling ARP')
585                 config.no_arp = True
586             config.vlans = [int(opts.l2_loopback), int(opts.l2_loopback)]
587             # disable any form of interface config since we loop at the switch level
588             config.no_int_config = True
589             LOG.info('Running L2 loopback: using EXT chain/no ARP')
590
591         if opts.use_sriov_middle_net:
592             if (not config.sriov) or (config.service_chain != ChainType.PVVP):
593                 raise Exception("--use-sriov-middle-net is only valid for PVVP with SRIOV")
594             config.use_sriov_middle_net = True
595
596         if config.sriov and config.service_chain != ChainType.EXT:
597             # if sriov is requested (does not apply to ext chains)
598             # make sure the physnet names are specified
599             check_physnet("left", config.internal_networks.left)
600             check_physnet("right", config.internal_networks.right)
601             if config.service_chain == ChainType.PVVP and config.use_sriov_middle_net:
602                 check_physnet("middle", config.internal_networks.middle)
603
604         # show running config in json format
605         if opts.show_config:
606             print json.dumps(config, sort_keys=True, indent=4)
607             sys.exit(0)
608
609         # check that an empty openrc file (no OpenStack) is only allowed
610         # with EXT chain
611         if not config.openrc_file:
612             if config.service_chain == ChainType.EXT:
613                 LOG.info('EXT chain with OpenStack mode disabled')
614             else:
615                 raise Exception("openrc_file is empty in the configuration and is required")
616
617         # update the config in the config plugin as it might have changed
618         # in a copy of the dict (config plugin still holds the original dict)
619         config_plugin.set_config(config)
620
621         if opts.status or opts.cleanup or opts.force_cleanup:
622             status_cleanup(config, opts.cleanup, opts.force_cleanup)
623
624         # add file log if requested
625         if config.log_file:
626             log.add_file_logger(config.log_file)
627
628         openstack_spec = config_plugin.get_openstack_spec() if config.openrc_file \
629             else None
630
631         nfvbench_instance = NFVBench(config, openstack_spec, config_plugin, factory)
632
633         if opts.server:
634             if os.path.isdir(opts.server):
635                 server = WebSocketIoServer(opts.server, nfvbench_instance, fluent_logger)
636                 nfvbench_instance.set_notifier(server)
637                 try:
638                     port = int(opts.port)
639                 except ValueError:
640                     server.run(host=opts.host)
641                 else:
642                     server.run(host=opts.host, port=port)
643             else:
644                 print 'Invalid HTTP root directory: ' + opts.server
645                 sys.exit(1)
646         else:
647             with utils.RunLock():
648                 run_summary_required = True
649                 if unknown_opts:
650                     err_msg = 'Unknown options: ' + ' '.join(unknown_opts)
651                     LOG.error(err_msg)
652                     raise Exception(err_msg)
653
654                 # remove unfilled values
655                 opts = {k: v for k, v in vars(opts).iteritems() if v is not None}
656                 # get CLI args
657                 params = ' '.join(str(e) for e in sys.argv[1:])
658                 result = nfvbench_instance.run(opts, params)
659                 if 'error_message' in result:
660                     raise Exception(result['error_message'])
661
662                 if 'result' in result and result['status']:
663                     nfvbench_instance.save(result['result'])
664                     nfvbench_instance.prepare_summary(result['result'])
665     except Exception as exc:
666         run_summary_required = True
667         LOG.error({
668             'status': NFVBench.STATUS_ERROR,
669             'error_message': traceback.format_exc()
670         })
671         print str(exc)
672     finally:
673         if fluent_logger:
674             # only send a summary record if there was an actual nfvbench run or
675             # if an error/exception was logged.
676             fluent_logger.send_run_summary(run_summary_required)
677
678
679 if __name__ == '__main__':
680     main()