# Copyright(c) 2016, Oracle and/or its affiliates. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import json import jsonpickle import logging import os import tempfile import traceback import uuid import kollacli.i18n as u from kollacli.api.exceptions import FailedOperation from kollacli.api.exceptions import HostError from kollacli.api.exceptions import InvalidArgument from kollacli.api.exceptions import InvalidConfiguration from kollacli.api.exceptions import MissingArgument from kollacli.api.exceptions import NotInInventory from kollacli.common.sshutils import ssh_setup_host from kollacli.common.utils import get_admin_user from kollacli.common.utils import get_ansible_command from kollacli.common.utils import get_group_vars_dir from kollacli.common.utils import get_host_vars_dir from kollacli.common.utils import get_kollacli_etc from kollacli.common.utils import run_cmd from kollacli.common.utils import sync_read_file from kollacli.common.utils import sync_write_file ANSIBLE_SSH_USER = 'ansible_ssh_user' ANSIBLE_CONNECTION = 'ansible_connection' ANSIBLE_BECOME = 'ansible_become' INVENTORY_PATH = 'ansible/inventory.json' COMPUTE_GRP_NAME = 'compute' CONTROL_GRP_NAME = 'control' NETWORK_GRP_NAME = 'network' STORAGE_GRP_NAME = 'storage' DATABASE_GRP_NAME = 'database' DEPLOY_GROUPS = [ COMPUTE_GRP_NAME, CONTROL_GRP_NAME, NETWORK_GRP_NAME, STORAGE_GRP_NAME, DATABASE_GRP_NAME, ] SERVICES = { 'ceilometer': ['ceilometer-alarm-evaluator', 'ceilometer-alarm-notifier', 'ceilometer-api', 'ceilometer-central', 'ceilometer-collector', 'ceilometer-notification'], 'cinder': ['cinder-api', 'cinder-scheduler', 'cinder-backup', 'cinder-volume'], 'glance': ['glance-api', 'glance-registry'], 'haproxy': [], 'heat': ['heat-api', 'heat-api-cfn', 'heat-engine'], 'horizon': [], 'keystone': [], 'memcached': [], 'murano': ['murano-api', 'murano-engine'], 'mysqlcluster': ['mysqlcluster-api', 'mysqlcluster-mgmt', 'mysqlcluster-ndb'], 'neutron': ['neutron-server', 'neutron-agents'], 'nova': ['nova-api', 'nova-conductor', 'nova-consoleauth', 'nova-novncproxy', 'nova-scheduler'], 'rabbitmq': [], 'swift': ['swift-proxy-server', 'swift-account-server', 'swift-container-server', 'swift-object-server'], } DEFAULT_GROUPS = { 'ceilometer': CONTROL_GRP_NAME, 'cinder': CONTROL_GRP_NAME, 'glance': CONTROL_GRP_NAME, 'haproxy': CONTROL_GRP_NAME, 'heat': CONTROL_GRP_NAME, 'horizon': CONTROL_GRP_NAME, 'keystone': CONTROL_GRP_NAME, 'memcached': CONTROL_GRP_NAME, 'murano': CONTROL_GRP_NAME, 'mysqlcluster': CONTROL_GRP_NAME, 'neutron': NETWORK_GRP_NAME, 'nova': CONTROL_GRP_NAME, 'rabbitmq': CONTROL_GRP_NAME, 'swift': CONTROL_GRP_NAME, } DEFAULT_OVERRIDES = { 'cinder-backup': STORAGE_GRP_NAME, 'cinder-volume': STORAGE_GRP_NAME, 'mysqlcluster-ndb': DATABASE_GRP_NAME, 'neutron-server': CONTROL_GRP_NAME, 'swift-account-server': STORAGE_GRP_NAME, 'swift-container-server': STORAGE_GRP_NAME, 'swift-object-server': STORAGE_GRP_NAME, } # these groups cannot be deleted, they are required by kolla PROTECTED_GROUPS = [COMPUTE_GRP_NAME] LOG = logging.getLogger(__name__) def remove_temp_inventory(path): """remove temp inventory file and its parent directory""" if path: if os.path.exists(path): os.remove(path) dirpath = os.path.dirname(path) if os.path.exists(dirpath): os.rmdir(dirpath) class Host(object): class_version = 1 def __init__(self, hostname): self.name = hostname self.alias = '' self.is_mgmt = False self.hypervisor = '' self.vars = {} self.version = self.__class__.class_version def get_vars(self): return self.vars.copy() def set_var(self, name, value): self.vars[name] = value def upgrade(self): pass class HostGroup(object): class_version = 1 def __init__(self, name): self.name = name self.hostnames = [] self.vars = {} self.version = self.__class__.class_version def upgrade(self): pass def add_host(self, host): if host.name not in self.hostnames: self.hostnames.append(host.name) def remove_host(self, host): if host.name in self.hostnames: self.hostnames.remove(host.name) def get_hostnames(self): return self.hostnames def get_vars(self): return self.vars.copy() def set_var(self, name, value): self.vars[name] = value def clear_var(self, name): if name in self.vars: del self.vars[name] def set_remote(self, remote_flag): self.set_var(ANSIBLE_BECOME, 'yes') if remote_flag: # set the ssh info for all the servers in the group self.set_var(ANSIBLE_SSH_USER, get_admin_user()) self.clear_var(ANSIBLE_CONNECTION) else: # remove ssh info, add local connection type self.set_var(ANSIBLE_CONNECTION, 'local') self.clear_var(ANSIBLE_SSH_USER) class Service(object): class_version = 1 def __init__(self, name): self.name = name self._sub_servicenames = [] self._groupnames = [] self._vars = {} self.version = self.__class__.class_version def upgrade(self): pass def add_groupname(self, groupname): if groupname is not None and groupname not in self._groupnames: self._groupnames.append(groupname) def remove_groupname(self, groupname): if groupname in self._groupnames: self._groupnames.remove(groupname) def get_groupnames(self): return self._groupnames def get_sub_servicenames(self): return self._sub_servicenames def add_sub_servicename(self, sub_servicename): if sub_servicename not in self._sub_servicenames: self._sub_servicenames.append(sub_servicename) def get_vars(self): return self._vars.copy() class SubService(object): class_version = 1 def __init__(self, name): self.name = name # groups and parent services are mutually exclusive self._groupnames = [] self._parent_servicename = None self._vars = {} self.version = self.__class__.class_version def upgrade(self): pass def add_groupname(self, groupname): if groupname not in self._groupnames: self._groupnames.append(groupname) def remove_groupname(self, groupname): if groupname in self._groupnames: self._groupnames.remove(groupname) if not self._groupnames: # no groups left, re-associate to the parent for servicename in SERVICES: if self.name in SERVICES[servicename]: self.set_parent_servicename(servicename) break def get_groupnames(self): return self._groupnames def set_parent_servicename(self, parent_svc_name): self._parent_servicename = parent_svc_name self._groupnames = [] def get_parent_servicename(self): return self._parent_servicename def get_vars(self): return self.vars.copy() class Inventory(object): class_version = 3 """class version history 1: initial release """ def __init__(self): self._groups = {} # kv = name:object self._hosts = {} # kv = name:object self._services = {} # kv = name:object self._sub_services = {} # kv = name:object self.vars = {} self.version = self.__class__.class_version self.remote_mode = True # initialize the inventory to its defaults self._create_default_inventory() def upgrade(self): if self.version <= 1: # upgrade from inventory v1 # add ceilometer to inventory svc_name = 'ceilometer' svc = self.create_service(svc_name) # associate ceilometer with all groups that heat is in. clone_svc = self.get_service('heat') groups = clone_svc.get_groupnames() for group in groups: svc.add_groupname(group) # stitch sub-service to service and set override # groups for sub_svc_name in SERVICES[svc_name]: sub_svc = self.create_sub_service(sub_svc_name) sub_svc.set_parent_servicename(svc_name) svc.add_sub_servicename(sub_svc_name) if self.version <= 2: # upgrade from inventory v2 # some sub-services may be missing their parent associations. # they are now needed in v3. for svc in self.get_services(): for sub_svcname in svc.get_subservicenames(): sub_svc = self.get_sub_service(sub_svcname) sub_svc.set_parent_servicename(svc.name) # update the version and save upgraded inventory file self.version = self.__class__.class_version Inventory.save(self) @staticmethod def load(): """load the inventory from a pickle file""" inventory_path = os.path.join(get_kollacli_etc(), INVENTORY_PATH) data = '' try: if os.path.exists(inventory_path): data = sync_read_file(inventory_path) # The inventory path changed between v1 and v2. Need to change # path throughout the inventory. This has to be done before # the pickle decode. if 'kollacli.common.inventory' not in data: data = data.replace( '"py/object": "kollacli.ansible.inventory.', '"py/object": "kollacli.common.inventory.') if data.strip(): inventory = jsonpickle.decode(data) # upgrade version handling if inventory.version != inventory.class_version: inventory.upgrade() else: inventory = Inventory() except Exception: raise FailedOperation( u._('Loading inventory failed. : {error}') .format(error=traceback.format_exc())) return inventory @staticmethod def save(inventory): """Save the inventory in a pickle file""" inventory_path = os.path.join(get_kollacli_etc(), INVENTORY_PATH) try: # multiple trips thru json to render a readable inventory file data = jsonpickle.encode(inventory) data_str = json.loads(data) pretty_data = json.dumps(data_str, indent=4) sync_write_file(inventory_path, pretty_data) except Exception as e: raise FailedOperation( u._('Saving inventory failed. : {error}') .format(error=str(e))) def _create_default_inventory(self): # create the default groups for groupname in DEPLOY_GROUPS: self.add_group(groupname) # create the default services/sub_services & their default groups for svcname in SERVICES: svc = self.create_service(svcname) default_grpname = DEFAULT_GROUPS[svcname] svc.add_groupname(default_grpname) sub_svcnames = SERVICES[svcname] if sub_svcnames: for sub_svcname in sub_svcnames: # create a subservice svc.add_sub_servicename(sub_svcname) sub_svc = self.create_sub_service(sub_svcname) sub_svc.set_parent_servicename(svc.name) if sub_svc.name in DEFAULT_OVERRIDES: sub_svc.add_groupname(DEFAULT_OVERRIDES[sub_svc.name]) def get_hosts(self): return self._hosts.values() def get_hostnames(self): return list(self._hosts.keys()) def get_host(self, hostname): host = None if hostname in self._hosts: host = self._hosts[hostname] return host def add_host(self, hostname, groupname=None): """add host if groupname is none, create a new host if group name is not none, add host to group """ if groupname and groupname not in self._groups: raise NotInInventory(u._('Group'), groupname) if groupname and hostname not in self._hosts: # if a groupname is specified, the host must already exist raise NotInInventory(u._('Host'), hostname) if not groupname and not self.remote_mode and len(self._hosts) >= 1: raise InvalidConfiguration( u._('Cannot have more than one host when in local deploy ' 'mode.')) changed = False # create new host if it doesn't exist host = Host(hostname) if hostname not in self.get_hostnames(): # a new host is being added to the inventory changed = True self._hosts[hostname] = host # a host is to be added to an existing group elif groupname: group = self._groups[groupname] if hostname not in group.get_hostnames(): changed = True group.add_host(host) return changed def remove_all_hosts(self): """remove all hosts.""" hostnamess = self.get_hostnames() for hostname in hostnamess: self.remove_host(hostname) def remove_host(self, hostname, groupname=None): """remove host if groupname is none, delete host if group name is not none, remove host from group """ changed = False if groupname and groupname not in self._groups: raise NotInInventory(u._('Group'), groupname) if hostname not in self._hosts: return changed changed = True host = self._hosts[hostname] groups = self.get_groups(host) for group in groups: if not groupname or groupname == group.name: group.remove_host(host) host_vars = os.path.join(get_host_vars_dir(), hostname) if os.path.exists(host_vars): os.remove(host_vars) if not groupname: del self._hosts[hostname] return changed def setup_hosts(self, hosts_info): """setup multiple hosts hosts_info is a dict of format: {'hostname1': { 'password': password 'uname': user_name } } The uname entry is optional. """ failed_hosts = {} for hostname, host_info in hosts_info.items(): host = self.get_host(hostname) if not host: failed_hosts[hostname] = u._("Host doesn't exist.") continue if not host_info or 'password' not in host_info: failed_hosts[hostname] = u._('No password in yml file.') continue passwd = host_info['password'] uname = None if 'uname' in host_info: uname = host_info['uname'] try: self.setup_host(hostname, passwd, uname) except Exception as e: failed_hosts[hostname] = '%s' % e if failed_hosts: summary = '\n' for hostname, err in failed_hosts.items(): summary = summary + '- %s: %s\n' % (hostname, err) raise HostError( u._('Not all hosts were set up. : {reasons}') .format(reasons=summary)) else: LOG.info(u._LI('All hosts were successfully set up.')) def setup_host(self, hostname, password, uname=None): try: LOG.info( u._LI('Starting setup of host ({host}).') .format(host=hostname)) ssh_setup_host(hostname, password, uname) check_ok, msg = self.ssh_check_host(hostname) if not check_ok: raise Exception(u._('Post-setup ssh check failed. {err}') .format(err=msg)) LOG.info(u._LI('Host ({host}) setup succeeded.') .format(host=hostname)) except Exception as e: raise HostError( u._('Host ({host}) setup failed : {error}') .format(host=hostname, error=str(e))) return True def ssh_check_hosts(self, hostnames): """ssh check for hosts return {hostname: {'success': True|False, 'msg': message}} """ summary = {} for hostname in hostnames: is_ok, msg = self.ssh_check_host(hostname) summary[hostname] = {} summary[hostname]['success'] = is_ok summary[hostname]['msg'] = msg return summary def ssh_check_host(self, hostname): err_msg = None command_string = '/usr/bin/sudo -u %s %s -vvv ' % \ (get_admin_user(), get_ansible_command()) gen_file_path = self.create_json_gen_file() is_ok = True try: inventory_string = '-i ' + gen_file_path ping_string = ' %s %s' % (hostname, '-m ping') cmd = (command_string + inventory_string + ping_string) err_msg, output = run_cmd(cmd, False) except Exception as e: is_ok = False msg = ( u._('Host: ({host}) setup exception. : {error}') .format(host=hostname, error=str(e))) finally: self.remove_json_gen_file(gen_file_path) if err_msg: is_ok = False msg = ( u._('Host ({host}) ssh check failed. : {error} {message}') .format(host=hostname, error=err_msg, message=output)) else: msg = (u._LI('Host ({host}) ssh check succeeded.') .format(host=hostname)) return is_ok, msg def add_group(self, groupname): # Group names cannot overlap with service names: if groupname in self._services or groupname in self._sub_services: raise InvalidArgument( u._('Invalid group name. A service name ' 'cannot be used for a group name.')) if groupname not in self._groups: self._groups[groupname] = HostGroup(groupname) group = self._groups[groupname] group.set_remote(self.remote_mode) return group def remove_group(self, groupname): if groupname in PROTECTED_GROUPS: raise InvalidArgument( u._('Cannot remove {group} group. It is required by kolla.') .format(group=groupname)) # remove group from services & subservices for service in self._services.values(): service.remove_groupname(groupname) for subservice in self._sub_services.values(): subservice.remove_groupname(groupname) group_vars = os.path.join(get_group_vars_dir(), groupname) if os.path.exists(group_vars) and groupname != '__GLOBAL__': os.remove(group_vars) if groupname in self._groups: del self._groups[groupname] def get_group(self, groupname): group = None if groupname in self._groups: group = self._groups[groupname] return group def get_groupnames(self): return list(self._groups.keys()) def get_groups(self, host=None): """return all groups containing host if hosts is none, return all groups in inventory """ groups = [] if not host: groups = self._groups.values() else: for group in self._groups.values(): if host.name in group.get_hostnames(): groups.append(group) return groups def get_host_groups(self): """return { hostname : [groupnames] }""" host_groups = {} for host in self._hosts.values(): host_groups[host.name] = [] groups = self.get_groups(host) for group in groups: host_groups[host.name].append(group.name) return host_groups def get_group_services(self): """get groups and their services return { groupname: [servicenames] } """ group_services = {} for group in self.get_groups(): group_services[group.name] = [] for svc in self.get_services(): for groupname in svc.get_groupnames(): group_services[groupname].append(svc.name) for sub_svc in self.get_sub_services(): for groupname in sub_svc.get_groupnames(): group_services[groupname].append(sub_svc.name) return group_services def get_group_hosts(self): """return { groupname : [hostnames] }""" group_hosts = {} for group in self.get_groups(): group_hosts[group.name] = [] for hostname in group.get_hostnames(): group_hosts[group.name].append(hostname) return group_hosts def create_service(self, servicename): if servicename not in self._services: service = Service(servicename) self._services[servicename] = service return self._services[servicename] def delete_service(self, servicename): if servicename in self._services: del self._services[servicename] def get_services(self): return self._services.values() def get_service(self, servicename): service = None if servicename in self._services: service = self._services[servicename] return service def add_group_to_service(self, groupname, servicename): if groupname not in self._groups: raise NotInInventory(u._('Group'), groupname) if servicename in self._services: service = self.get_service(servicename) service.add_groupname(groupname) elif servicename in self._sub_services: sub_service = self.get_sub_service(servicename) sub_service.add_groupname(groupname) else: raise NotInInventory(u._('Service'), servicename) def remove_group_from_service(self, groupname, servicename): if groupname not in self._groups: raise NotInInventory(u._('Group'), groupname) if servicename in self._services: service = self.get_service(servicename) service.remove_groupname(groupname) elif servicename in self._sub_services: sub_service = self.get_sub_service(servicename) sub_service.remove_groupname(groupname) else: raise NotInInventory(u._('Service'), servicename) def create_sub_service(self, sub_servicename): if sub_servicename not in self._sub_services: sub_service = SubService(sub_servicename) self._sub_services[sub_servicename] = sub_service return self._sub_services[sub_servicename] def delete_sub_service(self, sub_servicename): if sub_servicename in self._sub_services: del self._sub_services[sub_servicename] def get_sub_services(self): return self._sub_services.values() def get_sub_service(self, sub_servicename): sub_service = None if sub_servicename in self._sub_services: sub_service = self._sub_services[sub_servicename] return sub_service def get_service_sub_services(self): """get services and their sub_services return { servicename: [sub_servicenames] } """ svc_sub_svcs = {} for service in self.get_services(): svc_sub_svcs[service.name] = [] svc_sub_svcs[service.name].extend(service.get_sub_servicenames()) return svc_sub_svcs def set_deploy_mode(self, remote_flag): if not remote_flag and len(self._hosts) > 1: raise InvalidConfiguration( u._('Cannot set local deploy mode when multiple hosts exist.')) self.remote_mode = remote_flag for group in self.get_groups(): group.set_remote(remote_flag) def get_ansible_json(self, inventory_filter=None): """generate json inventory for ansible The hosts and groups added to the json output for ansible will be filtered by the hostnames and groupnames in the deploy filters. This allows a more targeted deploy to a specific set of hosts or groups. typical ansible json format: { 'group': { 'hosts': [ '192.168.28.71', '192.168.28.72' ], 'vars': { 'ansible_ssh_user': 'johndoe', 'ansible_ssh_private_key_file': '~/.ssh/mykey', 'example_variable': 'value' } 'children': [ 'marietta', '5points' ] }, '_meta': { 'hostvars': { '192.168.28.71': { 'host_specific_var': 'bar' }, '192.168.28.72': { 'host_specific_var': 'foo' } } } } """ jdict = {} # if no filter provided, use all groups, all hosts deploy_hostnames = self.get_hostnames() deploy_groupnames = self.get_groupnames() if inventory_filter: if 'deploy_hosts' in inventory_filter: deploy_hostnames = inventory_filter['deploy_hosts'] if 'deploy_groups' in inventory_filter: deploy_groupnames = inventory_filter['deploy_groups'] # add hostgroups for group in self.get_groups(): jdict[group.name] = {} jdict[group.name]['hosts'] = [] if group.name in deploy_groupnames: jdict[group.name]['hosts'] = \ self._filter_hosts(group.get_hostnames(), deploy_hostnames) jdict[group.name]['children'] = [] jdict[group.name]['vars'] = group.get_vars() # add top-level services and what groups they are in for service in self.get_services(): jdict[service.name] = {} jdict[service.name]['children'] = service.get_groupnames() # add sub-services and their groups for sub_svc in self.get_sub_services(): jdict[sub_svc.name] = {} groupnames = sub_svc.get_groupnames() if groupnames: # sub-service is associated with a group(s) jdict[sub_svc.name]['children'] = groupnames else: # sub-service is associated with parent service jdict[sub_svc.name]['children'] = \ [sub_svc.get_parent_servicename()] # temporarily create group containing all hosts. this is needed for # ansible commands that are performed on hosts not yet in groups. group = self.add_group('__GLOBAL__') jdict[group.name] = {} jdict[group.name]['hosts'] = deploy_hostnames jdict[group.name]['vars'] = group.get_vars() self.remove_group(group.name) # process hosts vars jdict['_meta'] = {} jdict['_meta']['hostvars'] = {} for hostname in deploy_hostnames: host = self.get_host(hostname) if host: jdict['_meta']['hostvars'][hostname] = host.get_vars() return json.dumps(jdict) def _filter_hosts(self, initial_hostnames, deploy_hostnames): """filter out hosts not in deploy hosts Must preserve the ordering of hosts in the group. """ filtered_hostnames = [] for hostname in initial_hostnames: if hostname in deploy_hostnames: filtered_hostnames.append(hostname) return filtered_hostnames def create_json_gen_file(self, inventory_filter=None): """create json inventory file using filter ({}) The inventory will be placed in a directory in /tmp, with the directory name of form kolla_uuid.py, where uuid is a unique deployment id. return path to filtered json generator file """ json_out = self.get_ansible_json(inventory_filter) deploy_id = str(uuid.uuid4()) dirname = 'kolla_%s' % deploy_id dirpath = os.path.join(tempfile.gettempdir(), dirname) os.mkdir(dirpath) json_gen_path = os.path.join(dirpath, 'temp_inventory.py') with open(json_gen_path, 'w') as json_gen_file: json_gen_file.write('#!/usr/bin/env python\n') # the quotes here are significant. The json_out has double quotes # embedded in it so single quotes are needed to wrap it. json_gen_file.write("print('%s')" % json_out) # set executable by group os.chmod(json_gen_path, 0o555) # nosec return json_gen_path def remove_json_gen_file(self, path): remove_temp_inventory(path) def validate_hostnames(self, hostnames): if not hostnames: raise MissingArgument(u._('Host name(s)')) invalid_hosts = [] for hostname in hostnames: if hostname not in self._hosts: invalid_hosts.append(hostname) if invalid_hosts: raise NotInInventory(u._('Host'), invalid_hosts) def validate_groupnames(self, groupnames): if not groupnames: raise MissingArgument(u._('Group name(s)')) invalid_groups = [] for groupname in groupnames: if groupname not in self._groups: invalid_groups.append(groupname) if invalid_groups: raise NotInInventory(u._('Group'), invalid_groups) def validate_servicenames(self, servicenames): if not servicenames: raise MissingArgument(u._('Service name(s)')) invalid_services = [] for servicename in servicenames: if (servicename not in self._services and servicename not in self._sub_services): invalid_services.append(servicename) if invalid_services: raise NotInInventory(u._('Service'), invalid_services)