--- /dev/null
+#!/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())