0a00599443e01a6d516266efedc84edf004359a7
[it/test.git] / XTesting / kubeadm / inventory.py
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
38 from collections import OrderedDict
39 from ipaddress import ip_address
40 from ruamel.yaml import YAML
41
42 import os
43 import re
44 import subprocess
45 import sys
46
47 ROLES = ['all', 'kube_control_plane', 'kube_node', 'etcd', 'k8s_cluster',
48          'calico_rr']
49 PROTECTED_NAMES = ROLES
50 AVAILABLE_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}
54 yaml = YAML()
55 yaml.Representer.add_representer(OrderedDict, yaml.Representer.represent_dict)
56
57
58 def 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
65 CONFIG_FILE = os.environ.get("CONFIG_FILE", "./hosts.yaml")
66 # Remove the reference of KUBE_MASTERS after some deprecation cycles.
67 KUBE_CONTROL_HOSTS = int(os.environ.get("KUBE_CONTROL_HOSTS",
68                          os.environ.get("KUBE_MASTERS", 2)))
69 # Reconfigures cluster distribution at scale
70 SCALE_THRESHOLD = int(os.environ.get("SCALE_THRESHOLD", 50))
71 MASSIVE_SCALE_THRESHOLD = int(os.environ.get("MASSIVE_SCALE_THRESHOLD", 200))
72
73 DEBUG = get_var_as_bool("DEBUG", True)
74 HOST_PREFIX = os.environ.get("HOST_PREFIX", "node")
75 USE_REAL_HOSTNAME = get_var_as_bool("USE_REAL_HOSTNAME", False)
76
77 # Configurable as shell vars end
78
79
80 class 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 ...]
444 Examples: inventory.py 10.10.1.3 10.10.1.4 10.10.1.5
445
446 Available commands:
447 help - Display this message
448 print_cfg - Write inventory file to stdout
449 print_ips - Write a space-delimited list of IPs from "all" group
450 print_hostnames - Write a space-delimited list of Hostnames from "all" group
451 add - Adds specified hosts into an already existing inventory
452
453 Advanced usage:
454 Create new or overwrite old inventory file: inventory.py 10.10.1.5
455 Add another host after initial creation: inventory.py add 10.10.1.6
456 Add range of hosts: inventory.py 10.10.1.3-10.10.1.5
457 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
458 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
459 Delete a host: inventory.py -10.10.1.3
460 Delete a host by id: inventory.py -node1
461
462 Configurable env vars:
463 DEBUG                   Enable debug printing. Default: True
464 CONFIG_FILE             File to write config to Default: ./inventory/sample/hosts.yaml
465 HOST_PREFIX             Host prefix for generated hosts. Default: node
466 KUBE_CONTROL_HOSTS      Set the number of kube-control-planes. Default: 2
467 SCALE_THRESHOLD         Separate ETCD role if # of nodes >= 50
468 MASSIVE_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
485 def main(argv=None):
486     if not argv:
487         argv = sys.argv[1:]
488     KubesprayInventory(argv, CONFIG_FILE)
489     return 0
490
491
492 if __name__ == "__main__":
493     sys.exit(main())