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.

480 lines
20 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 subprocess
  43. import sys
  44. ROLES = ['all', 'kube_control_plane', 'kube_node', 'etcd', 'k8s_cluster',
  45. 'calico_rr']
  46. PROTECTED_NAMES = ROLES
  47. AVAILABLE_COMMANDS = ['help', 'print_cfg', 'print_ips', 'print_hostnames',
  48. 'load', 'add']
  49. _boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True,
  50. '0': False, 'no': False, 'false': False, 'off': False}
  51. yaml = YAML()
  52. yaml.Representer.add_representer(OrderedDict, yaml.Representer.represent_dict)
  53. def get_var_as_bool(name, default):
  54. value = os.environ.get(name, '')
  55. return _boolean_states.get(value.lower(), default)
  56. # Configurable as shell vars start
  57. CONFIG_FILE = os.environ.get("CONFIG_FILE", "./inventory/sample/hosts.yaml")
  58. # Remove the reference of KUBE_MASTERS after some deprecation cycles.
  59. KUBE_CONTROL_HOSTS = int(os.environ.get("KUBE_CONTROL_HOSTS",
  60. os.environ.get("KUBE_MASTERS", 2)))
  61. # Reconfigures cluster distribution at scale
  62. SCALE_THRESHOLD = int(os.environ.get("SCALE_THRESHOLD", 50))
  63. MASSIVE_SCALE_THRESHOLD = int(os.environ.get("MASSIVE_SCALE_THRESHOLD", 200))
  64. DEBUG = get_var_as_bool("DEBUG", True)
  65. HOST_PREFIX = os.environ.get("HOST_PREFIX", "node")
  66. USE_REAL_HOSTNAME = get_var_as_bool("USE_REAL_HOSTNAME", False)
  67. # Configurable as shell vars end
  68. class KubesprayInventory(object):
  69. def __init__(self, changed_hosts=None, config_file=None):
  70. self.config_file = config_file
  71. self.yaml_config = {}
  72. loadPreviousConfig = False
  73. printHostnames = False
  74. # See whether there are any commands to process
  75. if changed_hosts and changed_hosts[0] in AVAILABLE_COMMANDS:
  76. if changed_hosts[0] == "add":
  77. loadPreviousConfig = True
  78. changed_hosts = changed_hosts[1:]
  79. elif changed_hosts[0] == "print_hostnames":
  80. loadPreviousConfig = True
  81. printHostnames = True
  82. else:
  83. self.parse_command(changed_hosts[0], changed_hosts[1:])
  84. sys.exit(0)
  85. # If the user wants to remove a node, we need to load the config anyway
  86. if changed_hosts and changed_hosts[0][0] == "-":
  87. loadPreviousConfig = True
  88. if self.config_file and loadPreviousConfig: # Load previous YAML file
  89. try:
  90. self.hosts_file = open(config_file, 'r')
  91. self.yaml_config = yaml.load(self.hosts_file)
  92. except OSError as e:
  93. # I am assuming we are catching "cannot open file" exceptions
  94. print(e)
  95. sys.exit(1)
  96. if printHostnames:
  97. self.print_hostnames()
  98. sys.exit(0)
  99. self.ensure_required_groups(ROLES)
  100. if changed_hosts:
  101. changed_hosts = self.range2ips(changed_hosts)
  102. self.hosts = self.build_hostnames(changed_hosts,
  103. loadPreviousConfig)
  104. self.purge_invalid_hosts(self.hosts.keys(), PROTECTED_NAMES)
  105. self.set_all(self.hosts)
  106. self.set_k8s_cluster()
  107. etcd_hosts_count = 3 if len(self.hosts.keys()) >= 3 else 1
  108. self.set_etcd(list(self.hosts.keys())[:etcd_hosts_count])
  109. if len(self.hosts) >= SCALE_THRESHOLD:
  110. self.set_kube_control_plane(list(self.hosts.keys())[
  111. etcd_hosts_count:(etcd_hosts_count + KUBE_CONTROL_HOSTS)])
  112. else:
  113. self.set_kube_control_plane(
  114. list(self.hosts.keys())[:KUBE_CONTROL_HOSTS])
  115. self.set_kube_node(self.hosts.keys())
  116. if len(self.hosts) >= SCALE_THRESHOLD:
  117. self.set_calico_rr(list(self.hosts.keys())[:etcd_hosts_count])
  118. else: # Show help if no options
  119. self.show_help()
  120. sys.exit(0)
  121. self.write_config(self.config_file)
  122. def write_config(self, config_file):
  123. if config_file:
  124. with open(self.config_file, 'w') as f:
  125. yaml.dump(self.yaml_config, f)
  126. else:
  127. print("WARNING: Unable to save config. Make sure you set "
  128. "CONFIG_FILE env var.")
  129. def debug(self, msg):
  130. if DEBUG:
  131. print("DEBUG: {0}".format(msg))
  132. def get_ip_from_opts(self, optstring):
  133. if 'ip' in optstring:
  134. return optstring['ip']
  135. else:
  136. raise ValueError("IP parameter not found in options")
  137. def ensure_required_groups(self, groups):
  138. for group in groups:
  139. if group == 'all':
  140. self.debug("Adding group {0}".format(group))
  141. if group not in self.yaml_config:
  142. all_dict = OrderedDict([('hosts', OrderedDict({})),
  143. ('children', OrderedDict({}))])
  144. self.yaml_config = {'all': all_dict}
  145. else:
  146. self.debug("Adding group {0}".format(group))
  147. if group not in self.yaml_config['all']['children']:
  148. self.yaml_config['all']['children'][group] = {'hosts': {}}
  149. def get_host_id(self, host):
  150. '''Returns integer host ID (without padding) from a given hostname.'''
  151. try:
  152. short_hostname = host.split('.')[0]
  153. return int(re.findall("\\d+$", short_hostname)[-1])
  154. except IndexError:
  155. raise ValueError("Host name must end in an integer")
  156. # Keeps already specified hosts,
  157. # and adds or removes the hosts provided as an argument
  158. def build_hostnames(self, changed_hosts, loadPreviousConfig=False):
  159. existing_hosts = OrderedDict()
  160. highest_host_id = 0
  161. # Load already existing hosts from the YAML
  162. if loadPreviousConfig:
  163. try:
  164. for host in self.yaml_config['all']['hosts']:
  165. # Read configuration of an existing host
  166. hostConfig = self.yaml_config['all']['hosts'][host]
  167. existing_hosts[host] = hostConfig
  168. # If the existing host seems
  169. # to have been created automatically, detect its ID
  170. if host.startswith(HOST_PREFIX):
  171. host_id = self.get_host_id(host)
  172. if host_id > highest_host_id:
  173. highest_host_id = host_id
  174. except Exception as e:
  175. # I am assuming we are catching automatically
  176. # created hosts without IDs
  177. print(e)
  178. sys.exit(1)
  179. # FIXME(mattymo): Fix condition where delete then add reuses highest id
  180. next_host_id = highest_host_id + 1
  181. next_host = ""
  182. all_hosts = existing_hosts.copy()
  183. for host in changed_hosts:
  184. # Delete the host from config the hostname/IP has a "-" prefix
  185. if host[0] == "-":
  186. realhost = host[1:]
  187. if self.exists_hostname(all_hosts, realhost):
  188. self.debug("Marked {0} for deletion.".format(realhost))
  189. all_hosts.pop(realhost)
  190. elif self.exists_ip(all_hosts, realhost):
  191. self.debug("Marked {0} for deletion.".format(realhost))
  192. self.delete_host_by_ip(all_hosts, realhost)
  193. # Host/Argument starts with a digit,
  194. # then we assume its an IP address
  195. elif host[0].isdigit():
  196. if ',' in host:
  197. ip, access_ip = host.split(',')
  198. else:
  199. ip = host
  200. access_ip = host
  201. if self.exists_hostname(all_hosts, host):
  202. self.debug("Skipping existing host {0}.".format(host))
  203. continue
  204. elif self.exists_ip(all_hosts, ip):
  205. self.debug("Skipping existing host {0}.".format(ip))
  206. continue
  207. if USE_REAL_HOSTNAME:
  208. cmd = ("ssh -oStrictHostKeyChecking=no "
  209. + access_ip + " 'hostname -s'")
  210. next_host = subprocess.check_output(cmd, shell=True)
  211. next_host = next_host.strip().decode('ascii')
  212. else:
  213. # Generates a hostname because we have only an IP address
  214. next_host = "{0}{1}".format(HOST_PREFIX, next_host_id)
  215. next_host_id += 1
  216. # Uses automatically generated node name
  217. # in case we dont provide it.
  218. all_hosts[next_host] = {'ansible_host': access_ip,
  219. 'ip': ip,
  220. 'access_ip': access_ip}
  221. # Host/Argument starts with a letter, then we assume its a hostname
  222. elif host[0].isalpha():
  223. if ',' in host:
  224. try:
  225. hostname, ip, access_ip = host.split(',')
  226. except Exception:
  227. hostname, ip = host.split(',')
  228. access_ip = ip
  229. if self.exists_hostname(all_hosts, host):
  230. self.debug("Skipping existing host {0}.".format(host))
  231. continue
  232. elif self.exists_ip(all_hosts, ip):
  233. self.debug("Skipping existing host {0}.".format(ip))
  234. continue
  235. all_hosts[hostname] = {'ansible_host': access_ip,
  236. 'ip': ip,
  237. 'access_ip': access_ip}
  238. return all_hosts
  239. # Expand IP ranges into individual addresses
  240. def range2ips(self, hosts):
  241. reworked_hosts = []
  242. def ips(start_address, end_address):
  243. try:
  244. # Python 3.x
  245. start = int(ip_address(start_address))
  246. end = int(ip_address(end_address))
  247. except Exception:
  248. # Python 2.7
  249. start = int(ip_address(str(start_address)))
  250. end = int(ip_address(str(end_address)))
  251. return [ip_address(ip).exploded for ip in range(start, end + 1)]
  252. for host in hosts:
  253. if '-' in host and not (host.startswith('-') or host[0].isalpha()):
  254. start, end = host.strip().split('-')
  255. try:
  256. reworked_hosts.extend(ips(start, end))
  257. except ValueError:
  258. raise Exception("Range of ip_addresses isn't valid")
  259. else:
  260. reworked_hosts.append(host)
  261. return reworked_hosts
  262. def exists_hostname(self, existing_hosts, hostname):
  263. return hostname in existing_hosts.keys()
  264. def exists_ip(self, existing_hosts, ip):
  265. for host_opts in existing_hosts.values():
  266. if ip == self.get_ip_from_opts(host_opts):
  267. return True
  268. return False
  269. def delete_host_by_ip(self, existing_hosts, ip):
  270. for hostname, host_opts in existing_hosts.items():
  271. if ip == self.get_ip_from_opts(host_opts):
  272. del existing_hosts[hostname]
  273. return
  274. raise ValueError("Unable to find host by IP: {0}".format(ip))
  275. def purge_invalid_hosts(self, hostnames, protected_names=[]):
  276. for role in self.yaml_config['all']['children']:
  277. if role != 'k8s_cluster' and self.yaml_config['all']['children'][role]['hosts']: # noqa
  278. all_hosts = self.yaml_config['all']['children'][role]['hosts'].copy() # noqa
  279. for host in all_hosts.keys():
  280. if host not in hostnames and host not in protected_names:
  281. self.debug(
  282. "Host {0} removed from role {1}".format(host, role)) # noqa
  283. del self.yaml_config['all']['children'][role]['hosts'][host] # noqa
  284. # purge from all
  285. if self.yaml_config['all']['hosts']:
  286. all_hosts = self.yaml_config['all']['hosts'].copy()
  287. for host in all_hosts.keys():
  288. if host not in hostnames and host not in protected_names:
  289. self.debug("Host {0} removed from role all".format(host))
  290. del self.yaml_config['all']['hosts'][host]
  291. def add_host_to_group(self, group, host, opts=""):
  292. self.debug("adding host {0} to group {1}".format(host, group))
  293. if group == 'all':
  294. if self.yaml_config['all']['hosts'] is None:
  295. self.yaml_config['all']['hosts'] = {host: None}
  296. self.yaml_config['all']['hosts'][host] = opts
  297. elif group != 'k8s_cluster:children':
  298. if self.yaml_config['all']['children'][group]['hosts'] is None:
  299. self.yaml_config['all']['children'][group]['hosts'] = {
  300. host: None}
  301. else:
  302. self.yaml_config['all']['children'][group]['hosts'][host] = None # noqa
  303. def set_kube_control_plane(self, hosts):
  304. for host in hosts:
  305. self.add_host_to_group('kube_control_plane', host)
  306. def set_all(self, hosts):
  307. for host, opts in hosts.items():
  308. self.add_host_to_group('all', host, opts)
  309. def set_k8s_cluster(self):
  310. k8s_cluster = {'children': {'kube_control_plane': None,
  311. 'kube_node': None}}
  312. self.yaml_config['all']['children']['k8s_cluster'] = k8s_cluster
  313. def set_calico_rr(self, hosts):
  314. for host in hosts:
  315. if host in self.yaml_config['all']['children']['kube_control_plane']: # noqa
  316. self.debug("Not adding {0} to calico_rr group because it "
  317. "conflicts with kube_control_plane "
  318. "group".format(host))
  319. continue
  320. if host in self.yaml_config['all']['children']['kube_node']:
  321. self.debug("Not adding {0} to calico_rr group because it "
  322. "conflicts with kube_node group".format(host))
  323. continue
  324. self.add_host_to_group('calico_rr', host)
  325. def set_kube_node(self, hosts):
  326. for host in hosts:
  327. if len(self.yaml_config['all']['hosts']) >= SCALE_THRESHOLD:
  328. if host in self.yaml_config['all']['children']['etcd']['hosts']: # noqa
  329. self.debug("Not adding {0} to kube_node group because of "
  330. "scale deployment and host is in etcd "
  331. "group.".format(host))
  332. continue
  333. if len(self.yaml_config['all']['hosts']) >= MASSIVE_SCALE_THRESHOLD: # noqa
  334. if host in self.yaml_config['all']['children']['kube_control_plane']['hosts']: # noqa
  335. self.debug("Not adding {0} to kube_node group because of "
  336. "scale deployment and host is in "
  337. "kube_control_plane group.".format(host))
  338. continue
  339. self.add_host_to_group('kube_node', host)
  340. def set_etcd(self, hosts):
  341. for host in hosts:
  342. self.add_host_to_group('etcd', host)
  343. def load_file(self, files=None):
  344. '''Directly loads JSON to inventory.'''
  345. if not files:
  346. raise Exception("No input file specified.")
  347. import json
  348. for filename in list(files):
  349. # Try JSON
  350. try:
  351. with open(filename, 'r') as f:
  352. data = json.load(f)
  353. except ValueError:
  354. raise Exception("Cannot read %s as JSON, or CSV", filename)
  355. self.ensure_required_groups(ROLES)
  356. self.set_k8s_cluster()
  357. for group, hosts in data.items():
  358. self.ensure_required_groups([group])
  359. for host, opts in hosts.items():
  360. optstring = {'ansible_host': opts['ip'],
  361. 'ip': opts['ip'],
  362. 'access_ip': opts['ip']}
  363. self.add_host_to_group('all', host, optstring)
  364. self.add_host_to_group(group, host)
  365. self.write_config(self.config_file)
  366. def parse_command(self, command, args=None):
  367. if command == 'help':
  368. self.show_help()
  369. elif command == 'print_cfg':
  370. self.print_config()
  371. elif command == 'print_ips':
  372. self.print_ips()
  373. elif command == 'print_hostnames':
  374. self.print_hostnames()
  375. elif command == 'load':
  376. self.load_file(args)
  377. else:
  378. raise Exception("Invalid command specified.")
  379. def show_help(self):
  380. help_text = '''Usage: inventory.py ip1 [ip2 ...]
  381. Examples: inventory.py 10.10.1.3 10.10.1.4 10.10.1.5
  382. Available commands:
  383. help - Display this message
  384. print_cfg - Write inventory file to stdout
  385. print_ips - Write a space-delimited list of IPs from "all" group
  386. print_hostnames - Write a space-delimited list of Hostnames from "all" group
  387. add - Adds specified hosts into an already existing inventory
  388. Advanced usage:
  389. Create new or overwrite old inventory file: inventory.py 10.10.1.5
  390. Add another host after initial creation: inventory.py add 10.10.1.6
  391. Add range of hosts: inventory.py 10.10.1.3-10.10.1.5
  392. 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
  393. 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
  394. Delete a host: inventory.py -10.10.1.3
  395. Delete a host by id: inventory.py -node1
  396. Configurable env vars:
  397. DEBUG Enable debug printing. Default: True
  398. CONFIG_FILE File to write config to Default: ./inventory/sample/hosts.yaml
  399. HOST_PREFIX Host prefix for generated hosts. Default: node
  400. KUBE_CONTROL_HOSTS Set the number of kube-control-planes. Default: 2
  401. SCALE_THRESHOLD Separate ETCD role if # of nodes >= 50
  402. MASSIVE_SCALE_THRESHOLD Separate K8s control-plane and ETCD if # of nodes >= 200
  403. ''' # noqa
  404. print(help_text)
  405. def print_config(self):
  406. yaml.dump(self.yaml_config, sys.stdout)
  407. def print_hostnames(self):
  408. print(' '.join(self.yaml_config['all']['hosts'].keys()))
  409. def print_ips(self):
  410. ips = []
  411. for host, opts in self.yaml_config['all']['hosts'].items():
  412. ips.append(self.get_ip_from_opts(opts))
  413. print(' '.join(ips))
  414. def main(argv=None):
  415. if not argv:
  416. argv = sys.argv[1:]
  417. KubesprayInventory(argv, CONFIG_FILE)
  418. return 0
  419. if __name__ == "__main__":
  420. sys.exit(main())