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