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.

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