pceicicd | a1d10ac | 2022-11-10 23:59:17 +0000 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 3 | # you may not use this file except in compliance with the License. |
| 4 | # You may obtain a copy of the License at |
| 5 | # |
| 6 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 7 | # |
| 8 | # Unless required by applicable law or agreed to in writing, software |
| 9 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or |
| 11 | # implied. |
| 12 | # See the License for the specific language governing permissions and |
| 13 | # limitations under the License. |
| 14 | # |
| 15 | # Usage: inventory.py ip1 [ip2 ...] |
| 16 | # Examples: inventory.py 10.10.1.3 10.10.1.4 10.10.1.5 |
| 17 | # |
| 18 | # Advanced usage: |
| 19 | # Add another host after initial creation: inventory.py 10.10.1.5 |
| 20 | # Add range of hosts: inventory.py 10.10.1.3-10.10.1.5 |
| 21 | # Add hosts with different ip and access ip: |
| 22 | # inventory.py 10.0.0.1,192.168.10.1 10.0.0.2,192.168.10.2 10.0.0.3,192.168.1.3 |
| 23 | # Add hosts with a specific hostname, ip, and optional access ip: |
| 24 | # inventory.py first,10.0.0.1,192.168.10.1 second,10.0.0.2 last,10.0.0.3 |
| 25 | # Delete a host: inventory.py -10.10.1.3 |
| 26 | # Delete a host by id: inventory.py -node1 |
| 27 | # |
| 28 | # Load a YAML or JSON file with inventory data: inventory.py load hosts.yaml |
| 29 | # YAML file should be in the following format: |
| 30 | # group1: |
| 31 | # host1: |
| 32 | # ip: X.X.X.X |
| 33 | # var: val |
| 34 | # group2: |
| 35 | # host2: |
| 36 | # ip: X.X.X.X |
| 37 | |
| 38 | from collections import OrderedDict |
| 39 | from ipaddress import ip_address |
| 40 | from ruamel.yaml import YAML |
| 41 | |
| 42 | import os |
| 43 | import re |
| 44 | import subprocess |
| 45 | import sys |
| 46 | |
| 47 | ROLES = ['all', 'kube_control_plane', 'kube_node', 'etcd', 'k8s_cluster', |
| 48 | 'calico_rr'] |
| 49 | PROTECTED_NAMES = ROLES |
| 50 | AVAILABLE_COMMANDS = ['help', 'print_cfg', 'print_ips', 'print_hostnames', |
| 51 | 'load', 'add'] |
| 52 | _boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True, |
| 53 | '0': False, 'no': False, 'false': False, 'off': False} |
| 54 | yaml = YAML() |
| 55 | yaml.Representer.add_representer(OrderedDict, yaml.Representer.represent_dict) |
| 56 | |
| 57 | |
| 58 | def get_var_as_bool(name, default): |
| 59 | value = os.environ.get(name, '') |
| 60 | return _boolean_states.get(value.lower(), default) |
| 61 | |
| 62 | # Configurable as shell vars start |
| 63 | |
| 64 | |
| 65 | CONFIG_FILE = os.environ.get("CONFIG_FILE", "./hosts.yaml") |
| 66 | # Remove the reference of KUBE_MASTERS after some deprecation cycles. |
| 67 | KUBE_CONTROL_HOSTS = int(os.environ.get("KUBE_CONTROL_HOSTS", |
| 68 | os.environ.get("KUBE_MASTERS", 2))) |
| 69 | # Reconfigures cluster distribution at scale |
| 70 | SCALE_THRESHOLD = int(os.environ.get("SCALE_THRESHOLD", 50)) |
| 71 | MASSIVE_SCALE_THRESHOLD = int(os.environ.get("MASSIVE_SCALE_THRESHOLD", 200)) |
| 72 | |
| 73 | DEBUG = get_var_as_bool("DEBUG", True) |
| 74 | HOST_PREFIX = os.environ.get("HOST_PREFIX", "node") |
| 75 | USE_REAL_HOSTNAME = get_var_as_bool("USE_REAL_HOSTNAME", False) |
| 76 | |
| 77 | # Configurable as shell vars end |
| 78 | |
| 79 | |
| 80 | class KubesprayInventory(object): |
| 81 | |
| 82 | def __init__(self, changed_hosts=None, config_file=None): |
| 83 | self.config_file = config_file |
| 84 | self.yaml_config = {} |
| 85 | loadPreviousConfig = False |
| 86 | printHostnames = False |
| 87 | # See whether there are any commands to process |
| 88 | if changed_hosts and changed_hosts[0] in AVAILABLE_COMMANDS: |
| 89 | if changed_hosts[0] == "add": |
| 90 | loadPreviousConfig = True |
| 91 | changed_hosts = changed_hosts[1:] |
| 92 | elif changed_hosts[0] == "print_hostnames": |
| 93 | loadPreviousConfig = True |
| 94 | printHostnames = True |
| 95 | else: |
| 96 | self.parse_command(changed_hosts[0], changed_hosts[1:]) |
| 97 | sys.exit(0) |
| 98 | |
| 99 | # If the user wants to remove a node, we need to load the config anyway |
| 100 | if changed_hosts and changed_hosts[0][0] == "-": |
| 101 | loadPreviousConfig = True |
| 102 | |
| 103 | if self.config_file and loadPreviousConfig: # Load previous YAML file |
| 104 | try: |
| 105 | self.hosts_file = open(config_file, 'r') |
| 106 | self.yaml_config = yaml.load(self.hosts_file) |
| 107 | except OSError as e: |
| 108 | # I am assuming we are catching "cannot open file" exceptions |
| 109 | print(e) |
| 110 | sys.exit(1) |
| 111 | |
| 112 | if printHostnames: |
| 113 | self.print_hostnames() |
| 114 | sys.exit(0) |
| 115 | |
| 116 | self.ensure_required_groups(ROLES) |
| 117 | |
| 118 | if changed_hosts: |
| 119 | changed_hosts = self.range2ips(changed_hosts) |
| 120 | self.hosts = self.build_hostnames(changed_hosts, |
| 121 | loadPreviousConfig) |
| 122 | self.purge_invalid_hosts(self.hosts.keys(), PROTECTED_NAMES) |
| 123 | self.set_all(self.hosts) |
| 124 | self.set_k8s_cluster() |
| 125 | etcd_hosts_count = 3 if len(self.hosts.keys()) >= 3 else 1 |
| 126 | self.set_etcd(list(self.hosts.keys())[:etcd_hosts_count]) |
| 127 | if len(self.hosts) >= SCALE_THRESHOLD: |
| 128 | self.set_kube_control_plane(list(self.hosts.keys())[ |
| 129 | etcd_hosts_count:(etcd_hosts_count + KUBE_CONTROL_HOSTS)]) |
| 130 | else: |
| 131 | self.set_kube_control_plane( |
| 132 | list(self.hosts.keys())[:KUBE_CONTROL_HOSTS]) |
| 133 | self.set_kube_node(self.hosts.keys()) |
| 134 | if len(self.hosts) >= SCALE_THRESHOLD: |
| 135 | self.set_calico_rr(list(self.hosts.keys())[:etcd_hosts_count]) |
| 136 | else: # Show help if no options |
| 137 | self.show_help() |
| 138 | sys.exit(0) |
| 139 | |
| 140 | self.write_config(self.config_file) |
| 141 | |
| 142 | def write_config(self, config_file): |
| 143 | if config_file: |
| 144 | with open(self.config_file, 'w') as f: |
| 145 | yaml.dump(self.yaml_config, f) |
| 146 | |
| 147 | else: |
| 148 | print("WARNING: Unable to save config. Make sure you set " |
| 149 | "CONFIG_FILE env var.") |
| 150 | |
| 151 | def debug(self, msg): |
| 152 | if DEBUG: |
| 153 | print("DEBUG: {0}".format(msg)) |
| 154 | |
| 155 | def get_ip_from_opts(self, optstring): |
| 156 | if 'ip' in optstring: |
| 157 | return optstring['ip'] |
| 158 | else: |
| 159 | raise ValueError("IP parameter not found in options") |
| 160 | |
| 161 | def ensure_required_groups(self, groups): |
| 162 | for group in groups: |
| 163 | if group == 'all': |
| 164 | self.debug("Adding group {0}".format(group)) |
| 165 | if group not in self.yaml_config: |
| 166 | all_dict = OrderedDict([('hosts', OrderedDict({})), |
| 167 | ('children', OrderedDict({}))]) |
| 168 | self.yaml_config = {'all': all_dict} |
| 169 | else: |
| 170 | self.debug("Adding group {0}".format(group)) |
| 171 | if group not in self.yaml_config['all']['children']: |
| 172 | self.yaml_config['all']['children'][group] = {'hosts': {}} |
| 173 | |
| 174 | def get_host_id(self, host): |
| 175 | '''Returns integer host ID (without padding) from a given hostname.''' |
| 176 | try: |
| 177 | short_hostname = host.split('.')[0] |
| 178 | return int(re.findall("\\d+$", short_hostname)[-1]) |
| 179 | except IndexError: |
| 180 | raise ValueError("Host name must end in an integer") |
| 181 | |
| 182 | # Keeps already specified hosts, |
| 183 | # and adds or removes the hosts provided as an argument |
| 184 | def build_hostnames(self, changed_hosts, loadPreviousConfig=False): |
| 185 | existing_hosts = OrderedDict() |
| 186 | highest_host_id = 0 |
| 187 | # Load already existing hosts from the YAML |
| 188 | if loadPreviousConfig: |
| 189 | try: |
| 190 | for host in self.yaml_config['all']['hosts']: |
| 191 | # Read configuration of an existing host |
| 192 | hostConfig = self.yaml_config['all']['hosts'][host] |
| 193 | existing_hosts[host] = hostConfig |
| 194 | # If the existing host seems |
| 195 | # to have been created automatically, detect its ID |
| 196 | if host.startswith(HOST_PREFIX): |
| 197 | host_id = self.get_host_id(host) |
| 198 | if host_id > highest_host_id: |
| 199 | highest_host_id = host_id |
| 200 | except Exception as e: |
| 201 | # I am assuming we are catching automatically |
| 202 | # created hosts without IDs |
| 203 | print(e) |
| 204 | sys.exit(1) |
| 205 | |
| 206 | # FIXME(mattymo): Fix condition where delete then add reuses highest id |
| 207 | next_host_id = highest_host_id + 1 |
| 208 | next_host = "" |
| 209 | |
| 210 | username = os.environ.get("ANSIBLE_USER", 'osc_int') |
| 211 | password = os.environ.get("ANSIBLE_PASSWORD", 'osc_int') |
| 212 | |
| 213 | |
| 214 | all_hosts = existing_hosts.copy() |
| 215 | for host in changed_hosts: |
| 216 | # Delete the host from config the hostname/IP has a "-" prefix |
| 217 | if host[0] == "-": |
| 218 | realhost = host[1:] |
| 219 | if self.exists_hostname(all_hosts, realhost): |
| 220 | self.debug("Marked {0} for deletion.".format(realhost)) |
| 221 | all_hosts.pop(realhost) |
| 222 | elif self.exists_ip(all_hosts, realhost): |
| 223 | self.debug("Marked {0} for deletion.".format(realhost)) |
| 224 | self.delete_host_by_ip(all_hosts, realhost) |
| 225 | # Host/Argument starts with a digit, |
| 226 | # then we assume its an IP address |
| 227 | elif host[0].isdigit(): |
| 228 | if ',' in host: |
| 229 | ip, access_ip = host.split(',') |
| 230 | else: |
| 231 | ip = host |
| 232 | access_ip = host |
| 233 | if self.exists_hostname(all_hosts, host): |
| 234 | self.debug("Skipping existing host {0}.".format(host)) |
| 235 | continue |
| 236 | elif self.exists_ip(all_hosts, ip): |
| 237 | self.debug("Skipping existing host {0}.".format(ip)) |
| 238 | continue |
| 239 | |
| 240 | if USE_REAL_HOSTNAME: |
| 241 | cmd = ("ssh -oStrictHostKeyChecking=no " |
| 242 | + access_ip + " 'hostname -s'") |
| 243 | next_host = subprocess.check_output(cmd, shell=True) |
| 244 | next_host = next_host.strip().decode('ascii') |
| 245 | else: |
| 246 | # Generates a hostname because we have only an IP address |
| 247 | next_host = "{0}{1}".format(HOST_PREFIX, next_host_id) |
| 248 | next_host_id += 1 |
| 249 | # Uses automatically generated node name |
| 250 | # in case we dont provide it. |
| 251 | if os.getenv('ANSIBLE_SSH_KEY'): |
| 252 | all_hosts[next_host] = {'ansible_host': access_ip, |
| 253 | 'ansible_user': username, |
| 254 | 'ansible_ssh_private_key_file': os.getenv('ANSIBLE_SSH_KEY'), |
| 255 | 'ip': ip, |
| 256 | 'access_ip': access_ip} |
| 257 | else: |
| 258 | all_hosts[next_host] = {'ansible_host': access_ip, |
| 259 | 'ansible_user': username, |
| 260 | 'ansible_password': password, |
| 261 | 'ip': ip, |
| 262 | 'access_ip': access_ip} |
| 263 | # Host/Argument starts with a letter, then we assume its a hostname |
| 264 | elif host[0].isalpha(): |
| 265 | if ',' in host: |
| 266 | try: |
| 267 | hostname, ip, access_ip = host.split(',') |
| 268 | except Exception: |
| 269 | hostname, ip = host.split(',') |
| 270 | access_ip = ip |
| 271 | if self.exists_hostname(all_hosts, host): |
| 272 | self.debug("Skipping existing host {0}.".format(host)) |
| 273 | continue |
| 274 | elif self.exists_ip(all_hosts, ip): |
| 275 | self.debug("Skipping existing host {0}.".format(ip)) |
| 276 | continue |
| 277 | all_hosts[hostname] = {'ansible_host': access_ip, |
| 278 | 'ip': ip, |
| 279 | 'access_ip': access_ip} |
| 280 | return all_hosts |
| 281 | |
| 282 | # Expand IP ranges into individual addresses |
| 283 | def range2ips(self, hosts): |
| 284 | reworked_hosts = [] |
| 285 | |
| 286 | def ips(start_address, end_address): |
| 287 | try: |
| 288 | # Python 3.x |
| 289 | start = int(ip_address(start_address)) |
| 290 | end = int(ip_address(end_address)) |
| 291 | except Exception: |
| 292 | # Python 2.7 |
| 293 | start = int(ip_address(str(start_address))) |
| 294 | end = int(ip_address(str(end_address))) |
| 295 | return [ip_address(ip).exploded for ip in range(start, end + 1)] |
| 296 | |
| 297 | for host in hosts: |
| 298 | if '-' in host and not (host.startswith('-') or host[0].isalpha()): |
| 299 | start, end = host.strip().split('-') |
| 300 | try: |
| 301 | reworked_hosts.extend(ips(start, end)) |
| 302 | except ValueError: |
| 303 | raise Exception("Range of ip_addresses isn't valid") |
| 304 | else: |
| 305 | reworked_hosts.append(host) |
| 306 | return reworked_hosts |
| 307 | |
| 308 | def exists_hostname(self, existing_hosts, hostname): |
| 309 | return hostname in existing_hosts.keys() |
| 310 | |
| 311 | def exists_ip(self, existing_hosts, ip): |
| 312 | for host_opts in existing_hosts.values(): |
| 313 | if ip == self.get_ip_from_opts(host_opts): |
| 314 | return True |
| 315 | return False |
| 316 | |
| 317 | def delete_host_by_ip(self, existing_hosts, ip): |
| 318 | for hostname, host_opts in existing_hosts.items(): |
| 319 | if ip == self.get_ip_from_opts(host_opts): |
| 320 | del existing_hosts[hostname] |
| 321 | return |
| 322 | raise ValueError("Unable to find host by IP: {0}".format(ip)) |
| 323 | |
| 324 | def purge_invalid_hosts(self, hostnames, protected_names=[]): |
| 325 | for role in self.yaml_config['all']['children']: |
| 326 | if role != 'k8s_cluster' and self.yaml_config['all']['children'][role]['hosts']: # noqa |
| 327 | all_hosts = self.yaml_config['all']['children'][role]['hosts'].copy() # noqa |
| 328 | for host in all_hosts.keys(): |
| 329 | if host not in hostnames and host not in protected_names: |
| 330 | self.debug( |
| 331 | "Host {0} removed from role {1}".format(host, role)) # noqa |
| 332 | del self.yaml_config['all']['children'][role]['hosts'][host] # noqa |
| 333 | # purge from all |
| 334 | if self.yaml_config['all']['hosts']: |
| 335 | all_hosts = self.yaml_config['all']['hosts'].copy() |
| 336 | for host in all_hosts.keys(): |
| 337 | if host not in hostnames and host not in protected_names: |
| 338 | self.debug("Host {0} removed from role all".format(host)) |
| 339 | del self.yaml_config['all']['hosts'][host] |
| 340 | |
| 341 | def add_host_to_group(self, group, host, opts=""): |
| 342 | self.debug("adding host {0} to group {1}".format(host, group)) |
| 343 | if group == 'all': |
| 344 | if self.yaml_config['all']['hosts'] is None: |
| 345 | self.yaml_config['all']['hosts'] = {host: None} |
| 346 | self.yaml_config['all']['hosts'][host] = opts |
| 347 | elif group != 'k8s_cluster:children': |
| 348 | if self.yaml_config['all']['children'][group]['hosts'] is None: |
| 349 | self.yaml_config['all']['children'][group]['hosts'] = { |
| 350 | host: None} |
| 351 | else: |
| 352 | self.yaml_config['all']['children'][group]['hosts'][host] = None # noqa |
| 353 | |
| 354 | def set_kube_control_plane(self, hosts): |
| 355 | for host in hosts: |
| 356 | self.add_host_to_group('kube_control_plane', host) |
| 357 | |
| 358 | def set_all(self, hosts): |
| 359 | for host, opts in hosts.items(): |
| 360 | self.add_host_to_group('all', host, opts) |
| 361 | |
| 362 | def set_k8s_cluster(self): |
| 363 | k8s_cluster = {'children': {'kube_control_plane': None, |
| 364 | 'kube_node': None}} |
| 365 | self.yaml_config['all']['children']['k8s_cluster'] = k8s_cluster |
| 366 | |
| 367 | def set_calico_rr(self, hosts): |
| 368 | for host in hosts: |
| 369 | if host in self.yaml_config['all']['children']['kube_control_plane']: # noqa |
| 370 | self.debug("Not adding {0} to calico_rr group because it " |
| 371 | "conflicts with kube_control_plane " |
| 372 | "group".format(host)) |
| 373 | continue |
| 374 | if host in self.yaml_config['all']['children']['kube_node']: |
| 375 | self.debug("Not adding {0} to calico_rr group because it " |
| 376 | "conflicts with kube_node group".format(host)) |
| 377 | continue |
| 378 | self.add_host_to_group('calico_rr', host) |
| 379 | |
| 380 | def set_kube_node(self, hosts): |
| 381 | for host in hosts: |
| 382 | if len(self.yaml_config['all']['hosts']) >= SCALE_THRESHOLD: |
| 383 | if host in self.yaml_config['all']['children']['etcd']['hosts']: # noqa |
| 384 | self.debug("Not adding {0} to kube_node group because of " |
| 385 | "scale deployment and host is in etcd " |
| 386 | "group.".format(host)) |
| 387 | continue |
| 388 | if len(self.yaml_config['all']['hosts']) >= MASSIVE_SCALE_THRESHOLD: # noqa |
| 389 | if host in self.yaml_config['all']['children']['kube_control_plane']['hosts']: # noqa |
| 390 | self.debug("Not adding {0} to kube_node group because of " |
| 391 | "scale deployment and host is in " |
| 392 | "kube_control_plane group.".format(host)) |
| 393 | continue |
| 394 | self.add_host_to_group('kube_node', host) |
| 395 | |
| 396 | def set_etcd(self, hosts): |
| 397 | for host in hosts: |
| 398 | self.add_host_to_group('etcd', host) |
| 399 | |
| 400 | def load_file(self, files=None): |
| 401 | '''Directly loads JSON to inventory.''' |
| 402 | |
| 403 | if not files: |
| 404 | raise Exception("No input file specified.") |
| 405 | |
| 406 | import json |
| 407 | |
| 408 | for filename in list(files): |
| 409 | # Try JSON |
| 410 | try: |
| 411 | with open(filename, 'r') as f: |
| 412 | data = json.load(f) |
| 413 | except ValueError: |
| 414 | raise Exception("Cannot read %s as JSON, or CSV", filename) |
| 415 | |
| 416 | self.ensure_required_groups(ROLES) |
| 417 | self.set_k8s_cluster() |
| 418 | for group, hosts in data.items(): |
| 419 | self.ensure_required_groups([group]) |
| 420 | for host, opts in hosts.items(): |
| 421 | optstring = {'ansible_host': opts['ip'], |
| 422 | 'ip': opts['ip'], |
| 423 | 'access_ip': opts['ip']} |
| 424 | self.add_host_to_group('all', host, optstring) |
| 425 | self.add_host_to_group(group, host) |
| 426 | self.write_config(self.config_file) |
| 427 | |
| 428 | def parse_command(self, command, args=None): |
| 429 | if command == 'help': |
| 430 | self.show_help() |
| 431 | elif command == 'print_cfg': |
| 432 | self.print_config() |
| 433 | elif command == 'print_ips': |
| 434 | self.print_ips() |
| 435 | elif command == 'print_hostnames': |
| 436 | self.print_hostnames() |
| 437 | elif command == 'load': |
| 438 | self.load_file(args) |
| 439 | else: |
| 440 | raise Exception("Invalid command specified.") |
| 441 | |
| 442 | def show_help(self): |
| 443 | help_text = '''Usage: inventory.py ip1 [ip2 ...] |
| 444 | Examples: inventory.py 10.10.1.3 10.10.1.4 10.10.1.5 |
| 445 | |
| 446 | Available commands: |
| 447 | help - Display this message |
| 448 | print_cfg - Write inventory file to stdout |
| 449 | print_ips - Write a space-delimited list of IPs from "all" group |
| 450 | print_hostnames - Write a space-delimited list of Hostnames from "all" group |
| 451 | add - Adds specified hosts into an already existing inventory |
| 452 | |
| 453 | Advanced usage: |
| 454 | Create new or overwrite old inventory file: inventory.py 10.10.1.5 |
| 455 | Add another host after initial creation: inventory.py add 10.10.1.6 |
| 456 | Add range of hosts: inventory.py 10.10.1.3-10.10.1.5 |
| 457 | Add hosts with different ip and access ip: inventory.py 10.0.0.1,192.168.10.1 10.0.0.2,192.168.10.2 10.0.0.3,192.168.10.3 |
| 458 | Add hosts with a specific hostname, ip, and optional access ip: first,10.0.0.1,192.168.10.1 second,10.0.0.2 last,10.0.0.3 |
| 459 | Delete a host: inventory.py -10.10.1.3 |
| 460 | Delete a host by id: inventory.py -node1 |
| 461 | |
| 462 | Configurable env vars: |
| 463 | DEBUG Enable debug printing. Default: True |
| 464 | CONFIG_FILE File to write config to Default: ./inventory/sample/hosts.yaml |
| 465 | HOST_PREFIX Host prefix for generated hosts. Default: node |
| 466 | KUBE_CONTROL_HOSTS Set the number of kube-control-planes. Default: 2 |
| 467 | SCALE_THRESHOLD Separate ETCD role if # of nodes >= 50 |
| 468 | MASSIVE_SCALE_THRESHOLD Separate K8s control-plane and ETCD if # of nodes >= 200 |
| 469 | ''' # noqa |
| 470 | print(help_text) |
| 471 | |
| 472 | def print_config(self): |
| 473 | yaml.dump(self.yaml_config, sys.stdout) |
| 474 | |
| 475 | def print_hostnames(self): |
| 476 | print(' '.join(self.yaml_config['all']['hosts'].keys())) |
| 477 | |
| 478 | def print_ips(self): |
| 479 | ips = [] |
| 480 | for host, opts in self.yaml_config['all']['hosts'].items(): |
| 481 | ips.append(self.get_ip_from_opts(opts)) |
| 482 | print(' '.join(ips)) |
| 483 | |
| 484 | |
| 485 | def main(argv=None): |
| 486 | if not argv: |
| 487 | argv = sys.argv[1:] |
| 488 | KubesprayInventory(argv, CONFIG_FILE) |
| 489 | return 0 |
| 490 | |
| 491 | |
| 492 | if __name__ == "__main__": |
| 493 | sys.exit(main()) |