#!/usr/bin/env python3 # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or # implied. # See the License for the specific language governing permissions and # limitations under the License. # # Usage: inventory.py ip1 [ip2 ...] # Examples: inventory.py 10.10.1.3 10.10.1.4 10.10.1.5 # # Advanced usage: # Add another host after initial creation: inventory.py 10.10.1.5 # Add range of hosts: inventory.py 10.10.1.3-10.10.1.5 # 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.1.3 # Add hosts with a specific hostname, ip, and optional access ip: # inventory.py first,10.0.0.1,192.168.10.1 second,10.0.0.2 last,10.0.0.3 # Delete a host: inventory.py -10.10.1.3 # Delete a host by id: inventory.py -node1 # # Load a YAML or JSON file with inventory data: inventory.py load hosts.yaml # YAML file should be in the following format: # group1: # host1: # ip: X.X.X.X # var: val # group2: # host2: # ip: X.X.X.X from collections import OrderedDict from ipaddress import ip_address from ruamel.yaml import YAML import os import re import subprocess import sys ROLES = ['all', 'kube_control_plane', 'kube_node', 'etcd', 'k8s_cluster', 'calico_rr'] PROTECTED_NAMES = ROLES AVAILABLE_COMMANDS = ['help', 'print_cfg', 'print_ips', 'print_hostnames', 'load', 'add'] _boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True, '0': False, 'no': False, 'false': False, 'off': False} yaml = YAML() yaml.Representer.add_representer(OrderedDict, yaml.Representer.represent_dict) def get_var_as_bool(name, default): value = os.environ.get(name, '') return _boolean_states.get(value.lower(), default) # Configurable as shell vars start CONFIG_FILE = os.environ.get("CONFIG_FILE", "./inventory/sample/hosts.yaml") # Remove the reference of KUBE_MASTERS after some deprecation cycles. KUBE_CONTROL_HOSTS = int(os.environ.get("KUBE_CONTROL_HOSTS", os.environ.get("KUBE_MASTERS", 2))) # Reconfigures cluster distribution at scale SCALE_THRESHOLD = int(os.environ.get("SCALE_THRESHOLD", 50)) MASSIVE_SCALE_THRESHOLD = int(os.environ.get("MASSIVE_SCALE_THRESHOLD", 200)) DEBUG = get_var_as_bool("DEBUG", True) HOST_PREFIX = os.environ.get("HOST_PREFIX", "node") USE_REAL_HOSTNAME = get_var_as_bool("USE_REAL_HOSTNAME", False) # Configurable as shell vars end class KubesprayInventory(object): def __init__(self, changed_hosts=None, config_file=None): self.config_file = config_file self.yaml_config = {} loadPreviousConfig = False printHostnames = False # See whether there are any commands to process if changed_hosts and changed_hosts[0] in AVAILABLE_COMMANDS: if changed_hosts[0] == "add": loadPreviousConfig = True changed_hosts = changed_hosts[1:] elif changed_hosts[0] == "print_hostnames": loadPreviousConfig = True printHostnames = True else: self.parse_command(changed_hosts[0], changed_hosts[1:]) sys.exit(0) # If the user wants to remove a node, we need to load the config anyway if changed_hosts and changed_hosts[0][0] == "-": loadPreviousConfig = True if self.config_file and loadPreviousConfig: # Load previous YAML file try: self.hosts_file = open(config_file, 'r') self.yaml_config = yaml.load(self.hosts_file) except OSError as e: # I am assuming we are catching "cannot open file" exceptions print(e) sys.exit(1) if printHostnames: self.print_hostnames() sys.exit(0) self.ensure_required_groups(ROLES) if changed_hosts: changed_hosts = self.range2ips(changed_hosts) self.hosts = self.build_hostnames(changed_hosts, loadPreviousConfig) self.purge_invalid_hosts(self.hosts.keys(), PROTECTED_NAMES) self.set_all(self.hosts) self.set_k8s_cluster() etcd_hosts_count = 3 if len(self.hosts.keys()) >= 3 else 1 self.set_etcd(list(self.hosts.keys())[:etcd_hosts_count]) if len(self.hosts) >= SCALE_THRESHOLD: self.set_kube_control_plane(list(self.hosts.keys())[ etcd_hosts_count:(etcd_hosts_count + KUBE_CONTROL_HOSTS)]) else: self.set_kube_control_plane( list(self.hosts.keys())[:KUBE_CONTROL_HOSTS]) self.set_kube_node(self.hosts.keys()) if len(self.hosts) >= SCALE_THRESHOLD: self.set_calico_rr(list(self.hosts.keys())[:etcd_hosts_count]) else: # Show help if no options self.show_help() sys.exit(0) self.write_config(self.config_file) def write_config(self, config_file): if config_file: with open(self.config_file, 'w') as f: yaml.dump(self.yaml_config, f) else: print("WARNING: Unable to save config. Make sure you set " "CONFIG_FILE env var.") def debug(self, msg): if DEBUG: print("DEBUG: {0}".format(msg)) def get_ip_from_opts(self, optstring): if 'ip' in optstring: return optstring['ip'] else: raise ValueError("IP parameter not found in options") def ensure_required_groups(self, groups): for group in groups: if group == 'all': self.debug("Adding group {0}".format(group)) if group not in self.yaml_config: all_dict = OrderedDict([('hosts', OrderedDict({})), ('children', OrderedDict({}))]) self.yaml_config = {'all': all_dict} else: self.debug("Adding group {0}".format(group)) if group not in self.yaml_config['all']['children']: self.yaml_config['all']['children'][group] = {'hosts': {}} def get_host_id(self, host): '''Returns integer host ID (without padding) from a given hostname.''' try: short_hostname = host.split('.')[0] return int(re.findall("\\d+$", short_hostname)[-1]) except IndexError: raise ValueError("Host name must end in an integer") # Keeps already specified hosts, # and adds or removes the hosts provided as an argument def build_hostnames(self, changed_hosts, loadPreviousConfig=False): existing_hosts = OrderedDict() highest_host_id = 0 # Load already existing hosts from the YAML if loadPreviousConfig: try: for host in self.yaml_config['all']['hosts']: # Read configuration of an existing host hostConfig = self.yaml_config['all']['hosts'][host] existing_hosts[host] = hostConfig # If the existing host seems # to have been created automatically, detect its ID if host.startswith(HOST_PREFIX): host_id = self.get_host_id(host) if host_id > highest_host_id: highest_host_id = host_id except Exception as e: # I am assuming we are catching automatically # created hosts without IDs print(e) sys.exit(1) # FIXME(mattymo): Fix condition where delete then add reuses highest id next_host_id = highest_host_id + 1 next_host = "" all_hosts = existing_hosts.copy() for host in changed_hosts: # Delete the host from config the hostname/IP has a "-" prefix if host[0] == "-": realhost = host[1:] if self.exists_hostname(all_hosts, realhost): self.debug("Marked {0} for deletion.".format(realhost)) all_hosts.pop(realhost) elif self.exists_ip(all_hosts, realhost): self.debug("Marked {0} for deletion.".format(realhost)) self.delete_host_by_ip(all_hosts, realhost) # Host/Argument starts with a digit, # then we assume its an IP address elif host[0].isdigit(): if ',' in host: ip, access_ip = host.split(',') else: ip = host access_ip = host if self.exists_hostname(all_hosts, host): self.debug("Skipping existing host {0}.".format(host)) continue elif self.exists_ip(all_hosts, ip): self.debug("Skipping existing host {0}.".format(ip)) continue if USE_REAL_HOSTNAME: cmd = ("ssh -oStrictHostKeyChecking=no " + access_ip + " 'hostname -s'") next_host = subprocess.check_output(cmd, shell=True) next_host = next_host.strip().decode('ascii') else: # Generates a hostname because we have only an IP address next_host = "{0}{1}".format(HOST_PREFIX, next_host_id) next_host_id += 1 # Uses automatically generated node name # in case we dont provide it. all_hosts[next_host] = {'ansible_host': access_ip, 'ip': ip, 'access_ip': access_ip} # Host/Argument starts with a letter, then we assume its a hostname elif host[0].isalpha(): if ',' in host: try: hostname, ip, access_ip = host.split(',') except Exception: hostname, ip = host.split(',') access_ip = ip if self.exists_hostname(all_hosts, host): self.debug("Skipping existing host {0}.".format(host)) continue elif self.exists_ip(all_hosts, ip): self.debug("Skipping existing host {0}.".format(ip)) continue all_hosts[hostname] = {'ansible_host': access_ip, 'ip': ip, 'access_ip': access_ip} return all_hosts # Expand IP ranges into individual addresses def range2ips(self, hosts): reworked_hosts = [] def ips(start_address, end_address): try: # Python 3.x start = int(ip_address(start_address)) end = int(ip_address(end_address)) except Exception: # Python 2.7 start = int(ip_address(str(start_address))) end = int(ip_address(str(end_address))) return [ip_address(ip).exploded for ip in range(start, end + 1)] for host in hosts: if '-' in host and not (host.startswith('-') or host[0].isalpha()): start, end = host.strip().split('-') try: reworked_hosts.extend(ips(start, end)) except ValueError: raise Exception("Range of ip_addresses isn't valid") else: reworked_hosts.append(host) return reworked_hosts def exists_hostname(self, existing_hosts, hostname): return hostname in existing_hosts.keys() def exists_ip(self, existing_hosts, ip): for host_opts in existing_hosts.values(): if ip == self.get_ip_from_opts(host_opts): return True return False def delete_host_by_ip(self, existing_hosts, ip): for hostname, host_opts in existing_hosts.items(): if ip == self.get_ip_from_opts(host_opts): del existing_hosts[hostname] return raise ValueError("Unable to find host by IP: {0}".format(ip)) def purge_invalid_hosts(self, hostnames, protected_names=[]): for role in self.yaml_config['all']['children']: if role != 'k8s_cluster' and self.yaml_config['all']['children'][role]['hosts']: # noqa all_hosts = self.yaml_config['all']['children'][role]['hosts'].copy() # noqa for host in all_hosts.keys(): if host not in hostnames and host not in protected_names: self.debug( "Host {0} removed from role {1}".format(host, role)) # noqa del self.yaml_config['all']['children'][role]['hosts'][host] # noqa # purge from all if self.yaml_config['all']['hosts']: all_hosts = self.yaml_config['all']['hosts'].copy() for host in all_hosts.keys(): if host not in hostnames and host not in protected_names: self.debug("Host {0} removed from role all".format(host)) del self.yaml_config['all']['hosts'][host] def add_host_to_group(self, group, host, opts=""): self.debug("adding host {0} to group {1}".format(host, group)) if group == 'all': if self.yaml_config['all']['hosts'] is None: self.yaml_config['all']['hosts'] = {host: None} self.yaml_config['all']['hosts'][host] = opts elif group != 'k8s_cluster:children': if self.yaml_config['all']['children'][group]['hosts'] is None: self.yaml_config['all']['children'][group]['hosts'] = { host: None} else: self.yaml_config['all']['children'][group]['hosts'][host] = None # noqa def set_kube_control_plane(self, hosts): for host in hosts: self.add_host_to_group('kube_control_plane', host) def set_all(self, hosts): for host, opts in hosts.items(): self.add_host_to_group('all', host, opts) def set_k8s_cluster(self): k8s_cluster = {'children': {'kube_control_plane': None, 'kube_node': None}} self.yaml_config['all']['children']['k8s_cluster'] = k8s_cluster def set_calico_rr(self, hosts): for host in hosts: if host in self.yaml_config['all']['children']['kube_control_plane']: # noqa self.debug("Not adding {0} to calico_rr group because it " "conflicts with kube_control_plane " "group".format(host)) continue if host in self.yaml_config['all']['children']['kube_node']: self.debug("Not adding {0} to calico_rr group because it " "conflicts with kube_node group".format(host)) continue self.add_host_to_group('calico_rr', host) def set_kube_node(self, hosts): for host in hosts: if len(self.yaml_config['all']['hosts']) >= SCALE_THRESHOLD: if host in self.yaml_config['all']['children']['etcd']['hosts']: # noqa self.debug("Not adding {0} to kube_node group because of " "scale deployment and host is in etcd " "group.".format(host)) continue if len(self.yaml_config['all']['hosts']) >= MASSIVE_SCALE_THRESHOLD: # noqa if host in self.yaml_config['all']['children']['kube_control_plane']['hosts']: # noqa self.debug("Not adding {0} to kube_node group because of " "scale deployment and host is in " "kube_control_plane group.".format(host)) continue self.add_host_to_group('kube_node', host) def set_etcd(self, hosts): for host in hosts: self.add_host_to_group('etcd', host) def load_file(self, files=None): '''Directly loads JSON to inventory.''' if not files: raise Exception("No input file specified.") import json for filename in list(files): # Try JSON try: with open(filename, 'r') as f: data = json.load(f) except ValueError: raise Exception("Cannot read %s as JSON, or CSV", filename) self.ensure_required_groups(ROLES) self.set_k8s_cluster() for group, hosts in data.items(): self.ensure_required_groups([group]) for host, opts in hosts.items(): optstring = {'ansible_host': opts['ip'], 'ip': opts['ip'], 'access_ip': opts['ip']} self.add_host_to_group('all', host, optstring) self.add_host_to_group(group, host) self.write_config(self.config_file) def parse_command(self, command, args=None): if command == 'help': self.show_help() elif command == 'print_cfg': self.print_config() elif command == 'print_ips': self.print_ips() elif command == 'print_hostnames': self.print_hostnames() elif command == 'load': self.load_file(args) else: raise Exception("Invalid command specified.") def show_help(self): help_text = '''Usage: inventory.py ip1 [ip2 ...] Examples: inventory.py 10.10.1.3 10.10.1.4 10.10.1.5 Available commands: help - Display this message print_cfg - Write inventory file to stdout print_ips - Write a space-delimited list of IPs from "all" group print_hostnames - Write a space-delimited list of Hostnames from "all" group add - Adds specified hosts into an already existing inventory Advanced usage: Create new or overwrite old inventory file: inventory.py 10.10.1.5 Add another host after initial creation: inventory.py add 10.10.1.6 Add range of hosts: inventory.py 10.10.1.3-10.10.1.5 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 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 Delete a host: inventory.py -10.10.1.3 Delete a host by id: inventory.py -node1 Configurable env vars: DEBUG Enable debug printing. Default: True CONFIG_FILE File to write config to Default: ./inventory/sample/hosts.yaml HOST_PREFIX Host prefix for generated hosts. Default: node KUBE_CONTROL_HOSTS Set the number of kube-control-planes. Default: 2 SCALE_THRESHOLD Separate ETCD role if # of nodes >= 50 MASSIVE_SCALE_THRESHOLD Separate K8s control-plane and ETCD if # of nodes >= 200 ''' # noqa print(help_text) def print_config(self): yaml.dump(self.yaml_config, sys.stdout) def print_hostnames(self): print(' '.join(self.yaml_config['all']['hosts'].keys())) def print_ips(self): ips = [] for host, opts in self.yaml_config['all']['hosts'].items(): ips.append(self.get_ip_from_opts(opts)) print(' '.join(ips)) def main(argv=None): if not argv: argv = sys.argv[1:] KubesprayInventory(argv, CONFIG_FILE) return 0 if __name__ == "__main__": sys.exit(main())