X-Git-Url: https://gerrit.o-ran-sc.org/r/gitweb?a=blobdiff_plain;f=XTesting%2Fkubeadm%2Finventory.py;fp=XTesting%2Fkubeadm%2Finventory.py;h=0a00599443e01a6d516266efedc84edf004359a7;hb=a1d10aced510b9794aee3006dc12a97f7514bcae;hp=0000000000000000000000000000000000000000;hpb=8cd731c571e117533f5c8a84690fbc7ffda0956b;p=it%2Ftest.git diff --git a/XTesting/kubeadm/inventory.py b/XTesting/kubeadm/inventory.py new file mode 100644 index 0000000..0a00599 --- /dev/null +++ b/XTesting/kubeadm/inventory.py @@ -0,0 +1,493 @@ +#!/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", "./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 = "" + + username = os.environ.get("ANSIBLE_USER", 'osc_int') + password = os.environ.get("ANSIBLE_PASSWORD", 'osc_int') + + + 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. + if os.getenv('ANSIBLE_SSH_KEY'): + all_hosts[next_host] = {'ansible_host': access_ip, + 'ansible_user': username, + 'ansible_ssh_private_key_file': os.getenv('ANSIBLE_SSH_KEY'), + 'ip': ip, + 'access_ip': access_ip} + else: + all_hosts[next_host] = {'ansible_host': access_ip, + 'ansible_user': username, + 'ansible_password': password, + '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())