You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

396 lines
15 KiB

  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. # Delete a host: inventory.py -10.10.1.3
  24. # Delete a host by id: inventory.py -node1
  25. #
  26. # Load a YAML or JSON file with inventory data: inventory.py load hosts.yaml
  27. # YAML file should be in the following format:
  28. # group1:
  29. # host1:
  30. # ip: X.X.X.X
  31. # var: val
  32. # group2:
  33. # host2:
  34. # ip: X.X.X.X
  35. from collections import OrderedDict
  36. from ipaddress import ip_address
  37. from ruamel.yaml import YAML
  38. import os
  39. import re
  40. import sys
  41. ROLES = ['all', 'kube-master', 'kube-node', 'etcd', 'k8s-cluster',
  42. 'calico-rr']
  43. PROTECTED_NAMES = ROLES
  44. AVAILABLE_COMMANDS = ['help', 'print_cfg', 'print_ips', 'load']
  45. _boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True,
  46. '0': False, 'no': False, 'false': False, 'off': False}
  47. yaml = YAML()
  48. yaml.Representer.add_representer(OrderedDict, yaml.Representer.represent_dict)
  49. def get_var_as_bool(name, default):
  50. value = os.environ.get(name, '')
  51. return _boolean_states.get(value.lower(), default)
  52. # Configurable as shell vars start
  53. CONFIG_FILE = os.environ.get("CONFIG_FILE", "./inventory/sample/hosts.yaml")
  54. # Reconfigures cluster distribution at scale
  55. SCALE_THRESHOLD = int(os.environ.get("SCALE_THRESHOLD", 50))
  56. MASSIVE_SCALE_THRESHOLD = int(os.environ.get("SCALE_THRESHOLD", 200))
  57. DEBUG = get_var_as_bool("DEBUG", True)
  58. HOST_PREFIX = os.environ.get("HOST_PREFIX", "node")
  59. # Configurable as shell vars end
  60. class KubesprayInventory(object):
  61. def __init__(self, changed_hosts=None, config_file=None):
  62. self.config_file = config_file
  63. self.yaml_config = {}
  64. if self.config_file:
  65. try:
  66. self.hosts_file = open(config_file, 'r')
  67. self.yaml_config = yaml.load(self.hosts_file)
  68. except FileNotFoundError:
  69. pass
  70. if changed_hosts and changed_hosts[0] in AVAILABLE_COMMANDS:
  71. self.parse_command(changed_hosts[0], changed_hosts[1:])
  72. sys.exit(0)
  73. self.ensure_required_groups(ROLES)
  74. if changed_hosts:
  75. changed_hosts = self.range2ips(changed_hosts)
  76. self.hosts = self.build_hostnames(changed_hosts)
  77. self.purge_invalid_hosts(self.hosts.keys(), PROTECTED_NAMES)
  78. self.set_all(self.hosts)
  79. self.set_k8s_cluster()
  80. etcd_hosts_count = 3 if len(self.hosts.keys()) >= 3 else 1
  81. self.set_etcd(list(self.hosts.keys())[:etcd_hosts_count])
  82. if len(self.hosts) >= SCALE_THRESHOLD:
  83. self.set_kube_master(list(self.hosts.keys())[etcd_hosts_count:5])
  84. else:
  85. self.set_kube_master(list(self.hosts.keys())[:2])
  86. self.set_kube_node(self.hosts.keys())
  87. if len(self.hosts) >= SCALE_THRESHOLD:
  88. self.set_calico_rr(list(self.hosts.keys())[:etcd_hosts_count])
  89. else: # Show help if no options
  90. self.show_help()
  91. sys.exit(0)
  92. self.write_config(self.config_file)
  93. def write_config(self, config_file):
  94. if config_file:
  95. with open(self.config_file, 'w') as f:
  96. yaml.dump(self.yaml_config, f)
  97. else:
  98. print("WARNING: Unable to save config. Make sure you set "
  99. "CONFIG_FILE env var.")
  100. def debug(self, msg):
  101. if DEBUG:
  102. print("DEBUG: {0}".format(msg))
  103. def get_ip_from_opts(self, optstring):
  104. if 'ip' in optstring:
  105. return optstring['ip']
  106. else:
  107. raise ValueError("IP parameter not found in options")
  108. def ensure_required_groups(self, groups):
  109. for group in groups:
  110. if group == 'all':
  111. self.debug("Adding group {0}".format(group))
  112. if group not in self.yaml_config:
  113. all_dict = OrderedDict([('hosts', OrderedDict({})),
  114. ('children', OrderedDict({}))])
  115. self.yaml_config = {'all': all_dict}
  116. else:
  117. self.debug("Adding group {0}".format(group))
  118. if group not in self.yaml_config['all']['children']:
  119. self.yaml_config['all']['children'][group] = {'hosts': {}}
  120. def get_host_id(self, host):
  121. '''Returns integer host ID (without padding) from a given hostname.'''
  122. try:
  123. short_hostname = host.split('.')[0]
  124. return int(re.findall("\\d+$", short_hostname)[-1])
  125. except IndexError:
  126. raise ValueError("Host name must end in an integer")
  127. def build_hostnames(self, changed_hosts):
  128. existing_hosts = OrderedDict()
  129. highest_host_id = 0
  130. try:
  131. for host in self.yaml_config['all']['hosts']:
  132. existing_hosts[host] = self.yaml_config['all']['hosts'][host]
  133. host_id = self.get_host_id(host)
  134. if host_id > highest_host_id:
  135. highest_host_id = host_id
  136. except Exception:
  137. pass
  138. # FIXME(mattymo): Fix condition where delete then add reuses highest id
  139. next_host_id = highest_host_id + 1
  140. all_hosts = existing_hosts.copy()
  141. for host in changed_hosts:
  142. if host[0] == "-":
  143. realhost = host[1:]
  144. if self.exists_hostname(all_hosts, realhost):
  145. self.debug("Marked {0} for deletion.".format(realhost))
  146. all_hosts.pop(realhost)
  147. elif self.exists_ip(all_hosts, realhost):
  148. self.debug("Marked {0} for deletion.".format(realhost))
  149. self.delete_host_by_ip(all_hosts, realhost)
  150. elif host[0].isdigit():
  151. if ',' in host:
  152. ip, access_ip = host.split(',')
  153. else:
  154. ip = host
  155. access_ip = host
  156. if self.exists_hostname(all_hosts, host):
  157. self.debug("Skipping existing host {0}.".format(host))
  158. continue
  159. elif self.exists_ip(all_hosts, ip):
  160. self.debug("Skipping existing host {0}.".format(ip))
  161. continue
  162. next_host = "{0}{1}".format(HOST_PREFIX, next_host_id)
  163. next_host_id += 1
  164. all_hosts[next_host] = {'ansible_host': access_ip,
  165. 'ip': ip,
  166. 'access_ip': access_ip}
  167. elif host[0].isalpha():
  168. raise Exception("Adding hosts by hostname is not supported.")
  169. return all_hosts
  170. def range2ips(self, hosts):
  171. reworked_hosts = []
  172. def ips(start_address, end_address):
  173. try:
  174. # Python 3.x
  175. start = int(ip_address(start_address))
  176. end = int(ip_address(end_address))
  177. except:
  178. # Python 2.7
  179. start = int(ip_address(unicode(start_address)))
  180. end = int(ip_address(unicode(end_address)))
  181. return [ip_address(ip).exploded for ip in range(start, end + 1)]
  182. for host in hosts:
  183. if '-' in host and not host.startswith('-'):
  184. start, end = host.strip().split('-')
  185. try:
  186. reworked_hosts.extend(ips(start, end))
  187. except ValueError:
  188. raise Exception("Range of ip_addresses isn't valid")
  189. else:
  190. reworked_hosts.append(host)
  191. return reworked_hosts
  192. def exists_hostname(self, existing_hosts, hostname):
  193. return hostname in existing_hosts.keys()
  194. def exists_ip(self, existing_hosts, ip):
  195. for host_opts in existing_hosts.values():
  196. if ip == self.get_ip_from_opts(host_opts):
  197. return True
  198. return False
  199. def delete_host_by_ip(self, existing_hosts, ip):
  200. for hostname, host_opts in existing_hosts.items():
  201. if ip == self.get_ip_from_opts(host_opts):
  202. del existing_hosts[hostname]
  203. return
  204. raise ValueError("Unable to find host by IP: {0}".format(ip))
  205. def purge_invalid_hosts(self, hostnames, protected_names=[]):
  206. for role in self.yaml_config['all']['children']:
  207. if role != 'k8s-cluster' and self.yaml_config['all']['children'][role]['hosts']: # noqa
  208. all_hosts = self.yaml_config['all']['children'][role]['hosts'].copy() # noqa
  209. for host in all_hosts.keys():
  210. if host not in hostnames and host not in protected_names:
  211. self.debug(
  212. "Host {0} removed from role {1}".format(host, role)) # noqa
  213. del self.yaml_config['all']['children'][role]['hosts'][host] # noqa
  214. # purge from all
  215. if self.yaml_config['all']['hosts']:
  216. all_hosts = self.yaml_config['all']['hosts'].copy()
  217. for host in all_hosts.keys():
  218. if host not in hostnames and host not in protected_names:
  219. self.debug("Host {0} removed from role all".format(host))
  220. del self.yaml_config['all']['hosts'][host]
  221. def add_host_to_group(self, group, host, opts=""):
  222. self.debug("adding host {0} to group {1}".format(host, group))
  223. if group == 'all':
  224. if self.yaml_config['all']['hosts'] is None:
  225. self.yaml_config['all']['hosts'] = {host: None}
  226. self.yaml_config['all']['hosts'][host] = opts
  227. elif group != 'k8s-cluster:children':
  228. if self.yaml_config['all']['children'][group]['hosts'] is None:
  229. self.yaml_config['all']['children'][group]['hosts'] = {
  230. host: None}
  231. else:
  232. self.yaml_config['all']['children'][group]['hosts'][host] = None # noqa
  233. def set_kube_master(self, hosts):
  234. for host in hosts:
  235. self.add_host_to_group('kube-master', host)
  236. def set_all(self, hosts):
  237. for host, opts in hosts.items():
  238. self.add_host_to_group('all', host, opts)
  239. def set_k8s_cluster(self):
  240. k8s_cluster = {'children': {'kube-master': None, 'kube-node': None}}
  241. self.yaml_config['all']['children']['k8s-cluster'] = k8s_cluster
  242. def set_calico_rr(self, hosts):
  243. for host in hosts:
  244. if host in self.yaml_config['all']['children']['kube-master']:
  245. self.debug("Not adding {0} to calico-rr group because it "
  246. "conflicts with kube-master group".format(host))
  247. continue
  248. if host in self.yaml_config['all']['children']['kube-node']:
  249. self.debug("Not adding {0} to calico-rr group because it "
  250. "conflicts with kube-node group".format(host))
  251. continue
  252. self.add_host_to_group('calico-rr', host)
  253. def set_kube_node(self, hosts):
  254. for host in hosts:
  255. if len(self.yaml_config['all']['hosts']) >= SCALE_THRESHOLD:
  256. if host in self.yaml_config['all']['children']['etcd']['hosts']: # noqa
  257. self.debug("Not adding {0} to kube-node group because of "
  258. "scale deployment and host is in etcd "
  259. "group.".format(host))
  260. continue
  261. if len(self.yaml_config['all']['hosts']) >= MASSIVE_SCALE_THRESHOLD: # noqa
  262. if host in self.yaml_config['all']['children']['kube-master']['hosts']: # noqa
  263. self.debug("Not adding {0} to kube-node group because of "
  264. "scale deployment and host is in kube-master "
  265. "group.".format(host))
  266. continue
  267. self.add_host_to_group('kube-node', host)
  268. def set_etcd(self, hosts):
  269. for host in hosts:
  270. self.add_host_to_group('etcd', host)
  271. def load_file(self, files=None):
  272. '''Directly loads JSON to inventory.'''
  273. if not files:
  274. raise Exception("No input file specified.")
  275. import json
  276. for filename in list(files):
  277. # Try JSON
  278. try:
  279. with open(filename, 'r') as f:
  280. data = json.load(f)
  281. except ValueError:
  282. raise Exception("Cannot read %s as JSON, or CSV", filename)
  283. self.ensure_required_groups(ROLES)
  284. self.set_k8s_cluster()
  285. for group, hosts in data.items():
  286. self.ensure_required_groups([group])
  287. for host, opts in hosts.items():
  288. optstring = {'ansible_host': opts['ip'],
  289. 'ip': opts['ip'],
  290. 'access_ip': opts['ip']}
  291. self.add_host_to_group('all', host, optstring)
  292. self.add_host_to_group(group, host)
  293. self.write_config(self.config_file)
  294. def parse_command(self, command, args=None):
  295. if command == 'help':
  296. self.show_help()
  297. elif command == 'print_cfg':
  298. self.print_config()
  299. elif command == 'print_ips':
  300. self.print_ips()
  301. elif command == 'load':
  302. self.load_file(args)
  303. else:
  304. raise Exception("Invalid command specified.")
  305. def show_help(self):
  306. help_text = '''Usage: inventory.py ip1 [ip2 ...]
  307. Examples: inventory.py 10.10.1.3 10.10.1.4 10.10.1.5
  308. Available commands:
  309. help - Display this message
  310. print_cfg - Write inventory file to stdout
  311. print_ips - Write a space-delimited list of IPs from "all" group
  312. Advanced usage:
  313. Add another host after initial creation: inventory.py 10.10.1.5
  314. Add range of hosts: inventory.py 10.10.1.3-10.10.1.5
  315. 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
  316. Delete a host: inventory.py -10.10.1.3
  317. Delete a host by id: inventory.py -node1
  318. Configurable env vars:
  319. DEBUG Enable debug printing. Default: True
  320. CONFIG_FILE File to write config to Default: ./inventory/sample/hosts.yaml
  321. HOST_PREFIX Host prefix for generated hosts. Default: node
  322. SCALE_THRESHOLD Separate ETCD role if # of nodes >= 50
  323. MASSIVE_SCALE_THRESHOLD Separate K8s master and ETCD if # of nodes >= 200
  324. ''' # noqa
  325. print(help_text)
  326. def print_config(self):
  327. yaml.dump(self.yaml_config, sys.stdout)
  328. def print_ips(self):
  329. ips = []
  330. for host, opts in self.yaml_config['all']['hosts'].items():
  331. ips.append(self.get_ip_from_opts(opts))
  332. print(' '.join(ips))
  333. def main(argv=None):
  334. if not argv:
  335. argv = sys.argv[1:]
  336. KubesprayInventory(argv, CONFIG_FILE)
  337. if __name__ == "__main__":
  338. sys.exit(main())