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.

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