# pylint: disable=bad-continuation,missing-docstring,no-self-use,invalid-name,too-many-instance-attributes,too-few-public-methods from __future__ import (absolute_import, print_function) import os import sys import logging import yaml from pkg_resources import resource_filename installer_log = logging.getLogger('installer') CONFIG_PERSIST_SETTINGS = [ 'ansible_ssh_user', 'ansible_callback_facts_yaml', 'ansible_inventory_path', 'ansible_log_path', 'deployment', 'version', 'variant', 'variant_subtype', 'variant_version', ] DEPLOYMENT_VARIABLES_BLACKLIST = [ 'hosts', 'roles', ] HOST_VARIABLES_BLACKLIST = [ 'ip', 'public_ip', 'hostname', 'public_hostname', 'node_labels', 'containerized', 'preconfigured', 'schedulable', 'other_variables', 'roles', ] DEFAULT_REQUIRED_FACTS = ['ip', 'public_ip', 'hostname', 'public_hostname'] PRECONFIGURED_REQUIRED_FACTS = ['hostname', 'public_hostname'] def print_read_config_error(error, path='the configuration file'): message = """ Error loading config. {}. See https://docs.openshift.com/enterprise/latest/install_config/install/quick_install.html#defining-an-installation-configuration-file for information on creating a configuration file or delete {} and re-run the installer. """ print(message.format(error, path)) class OOConfigFileError(Exception): """The provided config file path can't be read/written """ pass class OOConfigInvalidHostError(Exception): """ Host in config is missing both ip and hostname. """ pass class Host(object): """ A system we will or have installed OpenShift on. """ def __init__(self, **kwargs): self.ip = kwargs.get('ip', None) self.hostname = kwargs.get('hostname', None) self.public_ip = kwargs.get('public_ip', None) self.public_hostname = kwargs.get('public_hostname', None) self.connect_to = kwargs.get('connect_to', None) self.preconfigured = kwargs.get('preconfigured', None) self.schedulable = kwargs.get('schedulable', None) self.new_host = kwargs.get('new_host', None) self.containerized = kwargs.get('containerized', False) self.node_labels = kwargs.get('node_labels', '') # allowable roles: master, node, etcd, storage, master_lb self.roles = kwargs.get('roles', []) self.other_variables = kwargs.get('other_variables', {}) if self.connect_to is None: raise OOConfigInvalidHostError( "You must specify either an ip or hostname as 'connect_to'") def __str__(self): return self.connect_to def __repr__(self): return self.connect_to def to_dict(self): """ Used when exporting to yaml. """ d = {} for prop in ['ip', 'hostname', 'public_ip', 'public_hostname', 'connect_to', 'preconfigured', 'containerized', 'schedulable', 'roles', 'node_labels', ]: # If the property is defined (not None or False), export it: if getattr(self, prop): d[prop] = getattr(self, prop) for variable, value in self.other_variables.items(): d[variable] = value return d def is_master(self): return 'master' in self.roles def is_node(self): return 'node' in self.roles def is_master_lb(self): return 'master_lb' in self.roles def is_storage(self): return 'storage' in self.roles def is_etcd(self): """ Does this host have the etcd role """ return 'etcd' in self.roles def is_dedicated_node(self): """ Will this host be a dedicated node. (not a master) """ return self.is_node() and not self.is_master() def is_schedulable_node(self, all_hosts): """ Will this host be a node marked as schedulable. """ if not self.is_node(): return False if not self.is_master(): return True masters = [host for host in all_hosts if host.is_master()] nodes = [host for host in all_hosts if host.is_node()] if len(masters) == len(nodes): return True return False class Role(object): """ A role that will be applied to a host. """ def __init__(self, name, variables): self.name = name self.variables = variables def __str__(self): return self.name def __repr__(self): return self.name def to_dict(self): """ Used when exporting to yaml. """ d = {} for prop in ['name', 'variables']: # If the property is defined (not None or False), export it: if getattr(self, prop): d[prop] = getattr(self, prop) return d class Deployment(object): def __init__(self, **kwargs): self.hosts = kwargs.get('hosts', []) self.roles = kwargs.get('roles', {}) self.variables = kwargs.get('variables', {}) class OOConfig(object): default_dir = os.path.normpath( os.environ.get('XDG_CONFIG_HOME', os.environ.get('HOME', '') + '/.config/') + '/openshift/') default_file = '/installer.cfg.yml' def __init__(self, config_path): if config_path: self.config_path = os.path.normpath(config_path) else: self.config_path = os.path.normpath(self.default_dir + self.default_file) self.deployment = Deployment(hosts=[], roles={}, variables={}) self.settings = {} self._read_config() self._set_defaults() # pylint: disable=too-many-branches # Lots of different checks ran in a single method, could # use a little refactoring-love some time def _read_config(self): installer_log.debug("Attempting to read the OO Config") try: installer_log.debug("Attempting to see if the provided config file exists: %s", self.config_path) if os.path.exists(self.config_path): installer_log.debug("We think the config file exists: %s", self.config_path) with open(self.config_path, 'r') as cfgfile: loaded_config = yaml.safe_load(cfgfile.read()) if 'version' not in loaded_config: print_read_config_error('Legacy configuration file found', self.config_path) sys.exit(0) if loaded_config.get('version', '') == 'v1': loaded_config = self._upgrade_v1_config(loaded_config) try: host_list = loaded_config['deployment']['hosts'] role_list = loaded_config['deployment']['roles'] except KeyError as e: print_read_config_error("No such key: {}".format(e), self.config_path) sys.exit(0) for setting in CONFIG_PERSIST_SETTINGS: persisted_value = loaded_config.get(setting) if persisted_value is not None: self.settings[setting] = str(persisted_value) installer_log.debug("config: set (%s) to value (%s)", setting, persisted_value) # We've loaded any persisted configs, let's verify any # paths which are required for a correct and complete # install # - ansible_callback_facts_yaml - Settings from a # pervious run. If the file doesn't exist then we # will just warn about it for now and recollect the # facts. if self.settings.get('ansible_callback_facts_yaml', None) is not None: if not os.path.exists(self.settings['ansible_callback_facts_yaml']): # Cached callback facts file does not exist installer_log.warning("The specified 'ansible_callback_facts_yaml'" "file does not exist (%s)", self.settings['ansible_callback_facts_yaml']) installer_log.debug("Remote system facts will be collected again later") self.settings.pop('ansible_callback_facts_yaml') for setting in loaded_config['deployment']: try: if setting not in DEPLOYMENT_VARIABLES_BLACKLIST: self.deployment.variables[setting] = \ str(loaded_config['deployment'][setting]) except KeyError: continue # Parse the hosts into DTO objects: for host in host_list: host['other_variables'] = {} for variable, value in host.items(): if variable not in HOST_VARIABLES_BLACKLIST: host['other_variables'][variable] = value self.deployment.hosts.append(Host(**host)) # Parse the roles into Objects for name, variables in role_list.items(): self.deployment.roles.update({name: Role(name, variables)}) except IOError as ferr: raise OOConfigFileError('Cannot open config file "{}": {}'.format(ferr.filename, ferr.strerror)) except yaml.scanner.ScannerError: raise OOConfigFileError( 'Config file "{}" is not a valid YAML document'.format(self.config_path)) installer_log.debug("Parsed the config file") def _upgrade_v1_config(self, config): new_config_data = {} new_config_data['deployment'] = {} new_config_data['deployment']['hosts'] = [] new_config_data['deployment']['roles'] = {} new_config_data['deployment']['variables'] = {} role_list = {} if config.get('ansible_ssh_user', False): new_config_data['deployment']['ansible_ssh_user'] = config['ansible_ssh_user'] if config.get('variant', False): new_config_data['variant'] = config['variant'] if config.get('variant_version', False): new_config_data['variant_version'] = config['variant_version'] for host in config['hosts']: host_props = {} host_props['roles'] = [] host_props['connect_to'] = host['connect_to'] for prop in ['ip', 'public_ip', 'hostname', 'public_hostname', 'containerized', 'preconfigured']: host_props[prop] = host.get(prop, None) for role in ['master', 'node', 'master_lb', 'storage', 'etcd']: if host.get(role, False): host_props['roles'].append(role) role_list[role] = '' new_config_data['deployment']['hosts'].append(host_props) new_config_data['deployment']['roles'] = role_list return new_config_data def _set_defaults(self): installer_log.debug("Setting defaults, current OOConfig settings: %s", self.settings) if 'ansible_inventory_directory' not in self.settings: self.settings['ansible_inventory_directory'] = self._default_ansible_inv_dir() if not os.path.exists(self.settings['ansible_inventory_directory']): installer_log.debug("'ansible_inventory_directory' does not exist, " "creating it now (%s)", self.settings['ansible_inventory_directory']) os.makedirs(self.settings['ansible_inventory_directory']) else: installer_log.debug("We think this 'ansible_inventory_directory' " "is OK: %s", self.settings['ansible_inventory_directory']) if 'ansible_plugins_directory' not in self.settings: self.settings['ansible_plugins_directory'] = \ resource_filename(__name__, 'ansible_plugins') installer_log.debug("We think the ansible plugins directory should be: %s (it is not already set)", self.settings['ansible_plugins_directory']) else: installer_log.debug("The ansible plugins directory is already set: %s", self.settings['ansible_plugins_directory']) if 'version' not in self.settings: self.settings['version'] = 'v2' if 'ansible_callback_facts_yaml' not in self.settings: installer_log.debug("No 'ansible_callback_facts_yaml' in self.settings") self.settings['ansible_callback_facts_yaml'] = '%s/callback_facts.yaml' % \ self.settings['ansible_inventory_directory'] installer_log.debug("Value: %s", self.settings['ansible_callback_facts_yaml']) else: installer_log.debug("'ansible_callback_facts_yaml' already set " "in self.settings: %s", self.settings['ansible_callback_facts_yaml']) if 'ansible_ssh_user' not in self.settings: self.settings['ansible_ssh_user'] = '' if 'ansible_inventory_path' not in self.settings: self.settings['ansible_inventory_path'] = \ '{}/hosts'.format(os.path.dirname(self.config_path)) # clean up any empty sets empty_keys = [] for setting in self.settings: if not self.settings[setting]: empty_keys.append(setting) for key in empty_keys: self.settings.pop(key) installer_log.debug("Updated OOConfig settings: %s", self.settings) def _default_ansible_inv_dir(self): return os.path.normpath( os.path.dirname(self.config_path) + "/.ansible") def calc_missing_facts(self): """ Determine which host facts are not defined in the config. Returns a hash of host to a list of the missing facts. """ result = {} for host in self.deployment.hosts: missing_facts = [] if host.preconfigured: required_facts = PRECONFIGURED_REQUIRED_FACTS else: required_facts = DEFAULT_REQUIRED_FACTS for required_fact in required_facts: if not getattr(host, required_fact): missing_facts.append(required_fact) if len(missing_facts) > 0: result[host.connect_to] = missing_facts return result def save_to_disk(self): out_file = open(self.config_path, 'w') out_file.write(self.yaml()) out_file.close() def persist_settings(self): p_settings = {} for setting in CONFIG_PERSIST_SETTINGS: if setting in self.settings and self.settings[setting]: p_settings[setting] = self.settings[setting] p_settings['deployment'] = {} p_settings['deployment']['hosts'] = [] p_settings['deployment']['roles'] = {} for host in self.deployment.hosts: p_settings['deployment']['hosts'].append(host.to_dict()) for name, role in self.deployment.roles.items(): p_settings['deployment']['roles'][name] = role.variables for setting in self.deployment.variables: if setting not in DEPLOYMENT_VARIABLES_BLACKLIST: p_settings['deployment'][setting] = self.deployment.variables[setting] try: p_settings['variant'] = self.settings['variant'] p_settings['variant_version'] = self.settings['variant_version'] if self.settings['ansible_inventory_directory'] != self._default_ansible_inv_dir(): p_settings['ansible_inventory_directory'] = self.settings['ansible_inventory_directory'] except KeyError as e: print("Error persisting settings: {}".format(e)) sys.exit(0) return p_settings def yaml(self): return yaml.safe_dump(self.persist_settings(), default_flow_style=False) def __str__(self): return self.yaml() def get_host_roles_set(self): roles_set = set() for host in self.deployment.hosts: for role in host.roles: roles_set.add(role) return roles_set