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.

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