Adds Stack Update
[snaps.git] / snaps / openstack / utils / nova_utils.py
index 0a259b0..005b56f 100644 (file)
 
 import logging
 
+import enum
 import os
+import time
 from cryptography.hazmat.backends import default_backend
 from cryptography.hazmat.primitives import serialization
 from cryptography.hazmat.primitives.asymmetric import rsa
 from novaclient.client import Client
-from novaclient.exceptions import NotFound
+from novaclient.exceptions import NotFound, ClientException
 
+from snaps import file_utils
 from snaps.domain.flavor import Flavor
 from snaps.domain.keypair import Keypair
 from snaps.domain.project import ComputeQuotas
@@ -32,89 +35,119 @@ __author__ = 'spisarski'
 
 logger = logging.getLogger('nova_utils')
 
+POLL_INTERVAL = 3
+
 """
 Utilities for basic OpenStack Nova API calls
 """
 
 
-def nova_client(os_creds):
+def nova_client(os_creds, session=None):
     """
     Instantiates and returns a client for communications with OpenStack's Nova
     server
     :param os_creds: The connection credentials to the OpenStack API
+    :param session: the keystone session object (optional)
     :return: the client object
     """
     logger.debug('Retrieving Nova Client')
+    if not session:
+        session = keystone_utils.keystone_session(os_creds)
+
     return Client(os_creds.compute_api_version,
-                  session=keystone_utils.keystone_session(os_creds),
+                  session=session,
                   region_name=os_creds.region_name)
 
 
-def create_server(nova, neutron, glance, instance_settings, image_settings,
-                  keypair_settings=None):
+def create_server(nova, keystone, neutron, glance, instance_config,
+                  image_config, project_name, keypair_config=None):
     """
     Creates a VM instance
     :param nova: the nova client (required)
+    :param keystone: the keystone client for retrieving projects (required)
     :param neutron: the neutron client for retrieving ports (required)
     :param glance: the glance client (required)
-    :param instance_settings: the VM instance settings object (required)
-    :param image_settings: the VM's image settings object (required)
-    :param keypair_settings: the VM's keypair settings object (optional)
+    :param instance_config: the VMInstConfig object (required)
+    :param image_config: the VM's ImageConfig object (required)
+    :param project_name: the associated project name (required)
+    :param keypair_config: the VM's KeypairConfig object (optional)
     :return: a snaps.domain.VmInst object
     """
 
     ports = list()
 
-    for port_setting in instance_settings.port_settings:
-        ports.append(neutron_utils.get_port(
-            neutron, port_settings=port_setting))
+    for port_setting in instance_config.port_settings:
+        port = neutron_utils.get_port(
+            neutron, keystone, port_settings=port_setting,
+            project_name=project_name)
+        if port:
+            ports.append(port)
+        else:
+            raise Exception('Cannot find port named - ' + port_setting.name)
     nics = []
     for port in ports:
         kv = dict()
         kv['port-id'] = port.id
         nics.append(kv)
 
-    logger.info('Creating VM with name - ' + instance_settings.name)
+    logger.info('Creating VM with name - ' + instance_config.name)
     keypair_name = None
-    if keypair_settings:
-        keypair_name = keypair_settings.name
+    if keypair_config:
+        keypair_name = keypair_config.name
 
-    flavor = get_flavor_by_name(nova, instance_settings.flavor)
+    flavor = get_flavor_by_name(nova, instance_config.flavor)
     if not flavor:
         raise NovaException(
-            'Flavor not found with name - %s', instance_settings.flavor)
+            'Flavor not found with name - %s', instance_config.flavor)
 
-    image = glance_utils.get_image(glance, image_settings=image_settings)
+    image = glance_utils.get_image(glance, image_settings=image_config)
     if image:
-        args = {'name': instance_settings.name,
+        userdata = None
+        if instance_config.userdata:
+            if isinstance(instance_config.userdata, str):
+                userdata = instance_config.userdata + '\n'
+            elif (isinstance(instance_config.userdata, dict) and
+                  'script_file' in instance_config.userdata):
+                try:
+                    userdata = file_utils.read_file(
+                        instance_config.userdata['script_file'])
+                except Exception as e:
+                    logger.warn('error reading userdata file %s - %s',
+                                instance_config.userdata, e)
+        args = {'name': instance_config.name,
                 'flavor': flavor,
                 'image': image,
                 'nics': nics,
                 'key_name': keypair_name,
                 'security_groups':
-                    instance_settings.security_group_names,
-                'userdata': instance_settings.userdata}
+                    instance_config.security_group_names,
+                'userdata': userdata}
 
-        if instance_settings.availability_zone:
-            args['availability_zone'] = instance_settings.availability_zone
+        if instance_config.availability_zone:
+            args['availability_zone'] = instance_config.availability_zone
 
         server = nova.servers.create(**args)
-        return VmInst(name=server.name, inst_id=server.id,
-                      networks=server.networks)
+
+        return __map_os_server_obj_to_vm_inst(
+            neutron, keystone, server, project_name)
     else:
         raise NovaException(
             'Cannot create instance, image cannot be located with name %s',
-            image_settings.name)
+            image_config.name)
 
 
-def get_server(nova, vm_inst_settings=None, server_name=None):
+def get_server(nova, neutron, keystone, vm_inst_settings=None,
+               server_name=None, project_id=None):
     """
     Returns a VmInst object for the first server instance found.
     :param nova: the Nova client
-    :param vm_inst_settings: the VmInstanceSettings object from which to build
+    :param neutron: the Neutron client
+    :param keystone: the Keystone client
+    :param vm_inst_settings: the VmInstanceConfig object from which to build
                              the query if not None
     :param server_name: the server with this name to return if vm_inst_settings
                         is not None
+    :param project_id: the assocaited project ID
     :return: a snaps.domain.VmInst object or None if not found
     """
     search_opts = dict()
@@ -125,8 +158,74 @@ def get_server(nova, vm_inst_settings=None, server_name=None):
 
     servers = nova.servers.list(search_opts=search_opts)
     for server in servers:
-        return VmInst(name=server.name, inst_id=server.id,
-                      networks=server.networks)
+        return __map_os_server_obj_to_vm_inst(
+            neutron, keystone, server, project_id)
+
+
+def get_server_connection(nova, vm_inst_settings=None, server_name=None):
+    """
+    Returns a VmInst object for the first server instance found.
+    :param nova: the Nova client
+    :param vm_inst_settings: the VmInstanceConfig object from which to build
+                             the query if not None
+    :param server_name: the server with this name to return if vm_inst_settings
+                        is not None
+    :return: a snaps.domain.VmInst object or None if not found
+    """
+    search_opts = dict()
+    if vm_inst_settings:
+        search_opts['name'] = vm_inst_settings.name
+    elif server_name:
+        search_opts['name'] = server_name
+
+    servers = nova.servers.list(search_opts=search_opts)
+    for server in servers:
+        return server.links[0]
+
+
+def __map_os_server_obj_to_vm_inst(neutron, keystone, os_server,
+                                   project_name=None):
+    """
+    Returns a VmInst object for an OpenStack Server object
+    :param neutron: the Neutron client
+    :param keystone: the Keystone client
+    :param os_server: the OpenStack server object
+    :param project_name: the associated project name
+    :return: an equivalent SNAPS-OO VmInst domain object
+    """
+    sec_grp_names = list()
+    # VM must be active for 'security_groups' attr to be initialized
+    if hasattr(os_server, 'security_groups'):
+        for sec_group in os_server.security_groups:
+            if sec_group.get('name'):
+                sec_grp_names.append(sec_group.get('name'))
+
+    out_ports = list()
+    if len(os_server.networks) > 0:
+        for net_name, ips in os_server.networks.items():
+            network = neutron_utils.get_network(
+                neutron, keystone, network_name=net_name,
+                project_name=project_name)
+            if network:
+                ports = neutron_utils.get_ports(neutron, network, ips)
+                for port in ports:
+                    out_ports.append(port)
+            else:
+                raise NovaException(
+                    'Unable to locate network in project {} with '
+                    'name {}'.format(project_name, net_name))
+
+    volumes = None
+    if hasattr(os_server, 'os-extended-volumes:volumes_attached'):
+        volumes = getattr(os_server, 'os-extended-volumes:volumes_attached')
+
+    return VmInst(
+        name=os_server.name, inst_id=os_server.id,
+        image_id=os_server.image['id'], flavor_id=os_server.flavor['id'],
+        ports=out_ports, keypair_name=os_server.key_name,
+        sec_grp_names=sec_grp_names, volume_ids=volumes,
+        compute_host=os_server._info.get('OS-EXT-SRV-ATTR:host'),
+        availability_zone=os_server._info.get('OS-EXT-AZ:availability_zone'))
 
 
 def __get_latest_server_os_object(nova, server):
@@ -136,7 +235,17 @@ def __get_latest_server_os_object(nova, server):
     :param server: the domain VmInst object
     :return: the list of servers or None if not found
     """
-    return nova.servers.get(server.id)
+    return __get_latest_server_os_object_by_id(nova, server.id)
+
+
+def __get_latest_server_os_object_by_id(nova, server_id):
+    """
+    Returns a server with a given id
+    :param nova: the Nova client
+    :param server_id: the server's ID
+    :return: the list of servers or None if not found
+    """
+    return nova.servers.get(server_id)
 
 
 def get_server_status(nova, server):
@@ -165,16 +274,35 @@ def get_server_console_output(nova, server):
     return None
 
 
-def get_latest_server_object(nova, server):
+def get_latest_server_object(nova, neutron, keystone, server, project_name):
     """
     Returns a server with a given id
     :param nova: the Nova client
+    :param neutron: the Neutron client
+    :param keystone: the Keystone client
     :param server: the old server object
+    :param project_name: the associated project name
     :return: the list of servers or None if not found
     """
     server = __get_latest_server_os_object(nova, server)
-    return VmInst(name=server.name, inst_id=server.id,
-                  networks=server.networks)
+    return __map_os_server_obj_to_vm_inst(
+        neutron, keystone, server, project_name)
+
+
+def get_server_object_by_id(nova, neutron, keystone, server_id,
+                            project_name=None):
+    """
+    Returns a server with a given id
+    :param nova: the Nova client
+    :param neutron: the Neutron client
+    :param keystone: the Keystone client
+    :param server_id: the server's id
+    :param project_name: the associated project name
+    :return: an SNAPS-OO VmInst object or None if not found
+    """
+    server = __get_latest_server_os_object_by_id(nova, server_id)
+    return __map_os_server_obj_to_vm_inst(
+        neutron, keystone, server, project_name)
 
 
 def get_server_security_group_names(nova, server):
@@ -186,8 +314,9 @@ def get_server_security_group_names(nova, server):
     """
     out = list()
     os_vm_inst = __get_latest_server_os_object(nova, server)
-    for sec_grp_dict in os_vm_inst.security_groups:
-        out.append(sec_grp_dict['name'])
+    if hasattr(os_vm_inst, 'security_groups'):
+        for sec_grp_dict in os_vm_inst.security_groups:
+            out.append(sec_grp_dict['name'])
     return out
 
 
@@ -204,6 +333,22 @@ def get_server_info(nova, server):
     return None
 
 
+def reboot_server(nova, server, reboot_type=None):
+    """
+    Returns a dictionary of a VMs info as returned by OpenStack
+    :param nova: the Nova client
+    :param server: the old server object
+    :param reboot_type: Acceptable values 'SOFT', 'HARD'
+                        (api uses SOFT as the default)
+    :return: a dict of the info if VM exists else None
+    """
+    vm = __get_latest_server_os_object(nova, server)
+    if vm:
+        vm.reboot(reboot_type=reboot_type.value)
+    else:
+        raise ServerNotFoundError('Cannot locate server')
+
+
 def create_keys(key_size=2048):
     """
     Generates public and private keys
@@ -252,7 +397,7 @@ def save_keys_to_files(keys=None, pub_file_path=None, priv_file_path=None):
                 if public_handle:
                     public_handle.close()
 
-            os.chmod(pub_expand_file, 0o400)
+            os.chmod(pub_expand_file, 0o600)
             logger.info("Saved public key to - " + pub_expand_file)
         if priv_file_path:
             # To support '~'
@@ -273,7 +418,7 @@ def save_keys_to_files(keys=None, pub_file_path=None, priv_file_path=None):
                 if private_handle:
                     private_handle.close()
 
-            os.chmod(priv_expand_file, 0o400)
+            os.chmod(priv_expand_file, 0o600)
             logger.info("Saved private key to - " + priv_expand_file)
 
 
@@ -305,7 +450,8 @@ def upload_keypair(nova, name, key):
     """
     logger.info('Creating keypair with name - ' + name)
     os_kp = nova.keypairs.create(name=name, public_key=key.decode('utf-8'))
-    return Keypair(name=os_kp.name, id=os_kp.id, public_key=os_kp.public_key)
+    return Keypair(name=os_kp.name, kp_id=os_kp.id,
+                   public_key=os_kp.public_key, fingerprint=os_kp.fingerprint)
 
 
 def keypair_exists(nova, keypair_obj):
@@ -317,7 +463,7 @@ def keypair_exists(nova, keypair_obj):
     """
     try:
         os_kp = nova.keypairs.get(keypair_obj)
-        return Keypair(name=os_kp.name, id=os_kp.id,
+        return Keypair(name=os_kp.name, kp_id=os_kp.id,
                        public_key=os_kp.public_key)
     except:
         return None
@@ -334,12 +480,24 @@ def get_keypair_by_name(nova, name):
 
     for keypair in keypairs:
         if keypair.name == name:
-            return Keypair(name=keypair.name, id=keypair.id,
+            return Keypair(name=keypair.name, kp_id=keypair.id,
                            public_key=keypair.public_key)
 
     return None
 
 
+def get_keypair_by_id(nova, kp_id):
+    """
+    Returns a list of all available keypairs
+    :param nova: the Nova client
+    :param kp_id: the ID of the keypair to return
+    :return: the keypair object
+    """
+    keypair = nova.keypairs.get(kp_id)
+    return Keypair(name=keypair.name, kp_id=keypair.id,
+                   public_key=keypair.public_key)
+
+
 def delete_keypair(nova, key):
     """
     Deletes a keypair object from OpenStack
@@ -368,6 +526,21 @@ def get_availability_zone_hosts(nova, zone_name='nova'):
     return out
 
 
+def get_hypervisor_hosts(nova):
+    """
+    Returns the host names of all nova nodes with active hypervisors
+    :param nova: the Nova client
+    :return: a list of hypervisor host names
+    """
+    out = list()
+    hypervisors = nova.hypervisors.list()
+    for hypervisor in hypervisors:
+        if hypervisor.state == "up":
+            out.append(hypervisor.hypervisor_hostname)
+
+    return out
+
+
 def delete_vm_instance(nova, vm_inst):
     """
     Deletes a VM instance
@@ -377,15 +550,15 @@ def delete_vm_instance(nova, vm_inst):
     nova.servers.delete(vm_inst.id)
 
 
-def __get_os_flavor(nova, flavor):
+def __get_os_flavor(nova, flavor_id):
     """
     Returns to OpenStack flavor object by name
     :param nova: the Nova client
-    :param flavor: the SNAPS flavor domain object
+    :param flavor_id: the flavor's ID value
     :return: the OpenStack Flavor object
     """
     try:
-        return nova.flavors.get(flavor.id)
+        return nova.flavors.get(flavor_id)
     except NotFound:
         return None
 
@@ -397,7 +570,7 @@ def get_flavor(nova, flavor):
     :param flavor: the SNAPS flavor domain object
     :return: the SNAPS Flavor domain object
     """
-    os_flavor = __get_os_flavor(nova, flavor)
+    os_flavor = __get_os_flavor(nova, flavor.id)
     if os_flavor:
         return Flavor(
             name=os_flavor.name, id=os_flavor.id, ram=os_flavor.ram,
@@ -410,6 +583,22 @@ def get_flavor(nova, flavor):
         return None
 
 
+def get_flavor_by_id(nova, flavor_id):
+    """
+    Returns to OpenStack flavor object by name
+    :param nova: the Nova client
+    :param flavor_id: the flavor ID value
+    :return: the SNAPS Flavor domain object
+    """
+    os_flavor = __get_os_flavor(nova, flavor_id)
+    if os_flavor:
+        return Flavor(
+            name=os_flavor.name, id=os_flavor.id, ram=os_flavor.ram,
+            disk=os_flavor.disk, vcpus=os_flavor.vcpus,
+            ephemeral=os_flavor.ephemeral, swap=os_flavor.swap,
+            rxtx_factor=os_flavor.rxtx_factor, is_public=os_flavor.is_public)
+
+
 def __get_os_flavor_by_name(nova, name):
     """
     Returns to OpenStack flavor object by name
@@ -475,7 +664,7 @@ def set_flavor_keys(nova, flavor, metadata):
     :param flavor: the SNAPS flavor domain object
     :param metadata: the metadata to set
     """
-    os_flavor = __get_os_flavor(nova, flavor)
+    os_flavor = __get_os_flavor(nova, flavor.id)
     if os_flavor:
         os_flavor.set_keys(metadata)
 
@@ -486,7 +675,7 @@ def get_flavor_keys(nova, flavor):
     :param nova: the Nova client
     :param flavor: the SNAPS flavor domain object
     """
-    os_flavor = __get_os_flavor(nova, flavor)
+    os_flavor = __get_os_flavor(nova, flavor.id)
     if os_flavor:
         return os_flavor.get_keys()
 
@@ -498,7 +687,18 @@ def add_security_group(nova, vm, security_group_name):
     :param vm: the OpenStack server object (VM) to alter
     :param security_group_name: the name of the security group to add
     """
-    nova.servers.add_security_group(str(vm.id), security_group_name)
+    try:
+        nova.servers.add_security_group(str(vm.id), security_group_name)
+    except ClientException as e:
+        sec_grp_names = get_server_security_group_names(nova, vm)
+        if security_group_name in sec_grp_names:
+            logger.warn('Security group [%s] already added to VM [%s]',
+                        security_group_name, vm.name)
+            return
+
+        logger.error('Unexpected error while adding security group [%s] - %s',
+                     security_group_name, e)
+        raise
 
 
 def remove_security_group(nova, vm, security_group):
@@ -511,18 +711,6 @@ def remove_security_group(nova, vm, security_group):
     nova.servers.remove_security_group(str(vm.id), security_group.name)
 
 
-def add_floating_ip_to_server(nova, vm, floating_ip, ip_addr):
-    """
-    Adds a floating IP to a server instance
-    :param nova: the nova client
-    :param vm: VmInst domain object
-    :param floating_ip: FloatingIp domain object
-    :param ip_addr: the IP to which to bind the floating IP to
-    """
-    vm = __get_latest_server_os_object(nova, vm)
-    vm.add_floating_ip(floating_ip.ip, ip_addr)
-
-
 def get_compute_quotas(nova, project_id):
     """
     Returns a list of all available keypairs
@@ -549,7 +737,8 @@ def update_quotas(nova, project_id, compute_quotas):
     update_values['cores'] = compute_quotas.cores
     update_values['instances'] = compute_quotas.instances
     update_values['injected_files'] = compute_quotas.injected_files
-    update_values['injected_file_content_bytes'] = compute_quotas.injected_file_content_bytes
+    update_values['injected_file_content_bytes'] = (
+        compute_quotas.injected_file_content_bytes)
     update_values['ram'] = compute_quotas.ram
     update_values['fixed_ips'] = compute_quotas.fixed_ips
     update_values['key_pairs'] = compute_quotas.key_pairs
@@ -557,7 +746,91 @@ def update_quotas(nova, project_id, compute_quotas):
     return nova.quotas.update(project_id, **update_values)
 
 
+def attach_volume(nova, neutron, keystone, server, volume, project_name,
+                  timeout=120):
+    """
+    Attaches a volume to a server. When the timeout parameter is used, a VmInst
+    object with the proper volume updates is returned unless it has not been
+    updated in the allotted amount of time then an Exception will be raised.
+    :param nova: the nova client
+    :param neutron: the neutron client
+    :param keystone: the neutron client
+    :param server: the VMInst domain object
+    :param volume: the Volume domain object
+    :param project_name: the associated project name
+    :param timeout: denotes the amount of time to block to determine if the
+                    has been properly attached.
+    :return: updated VmInst object
+    """
+    nova.volumes.create_server_volume(server.id, volume.id)
+
+    start_time = time.time()
+    while time.time() < start_time + timeout:
+        vm = get_server_object_by_id(
+            nova, neutron, keystone, server.id, project_name)
+        for vol_dict in vm.volume_ids:
+            if volume.id == vol_dict['id']:
+                return vm
+        time.sleep(POLL_INTERVAL)
+
+    raise NovaException(
+        'Attach failed on volume - {} and server - {}'.format(
+            volume.id, server.id))
+
+
+def detach_volume(nova, neutron, keystone, server, volume, project_name,
+                  timeout=120):
+    """
+    Detaches a volume to a server. When the timeout parameter is used, a VmInst
+    object with the proper volume updates is returned unless it has not been
+    updated in the allotted amount of time then an Exception will be raised.
+    :param nova: the nova client
+    :param neutron: the neutron client
+    :param keystone: the keystone client
+    :param server: the VMInst domain object
+    :param volume: the Volume domain object
+    :param project_name: the associated project name
+    :param timeout: denotes the amount of time to block to determine if the
+                    has been properly detached.
+    :return: updated VmInst object
+    """
+    nova.volumes.delete_server_volume(server.id, volume.id)
+
+    start_time = time.time()
+    while time.time() < start_time + timeout:
+        vm = get_server_object_by_id(
+            nova, neutron, keystone, server.id, project_name)
+        if len(vm.volume_ids) == 0:
+            return vm
+        else:
+            ids = list()
+            for vol_dict in vm.volume_ids:
+                ids.append(vol_dict['id'])
+            if volume.id not in ids:
+                return vm
+        time.sleep(POLL_INTERVAL)
+
+    raise NovaException(
+        'Detach failed on volume - {} server - {}'.format(
+            volume.id, server.id))
+
+
+class RebootType(enum.Enum):
+    """
+    A rule's direction
+    """
+    soft = 'SOFT'
+    hard = 'HARD'
+
+
 class NovaException(Exception):
     """
     Exception when calls to the Keystone client cannot be served properly
     """
+
+
+class ServerNotFoundError(Exception):
+    """
+    Exception when operations to a VM/Server is requested and the OpenStack
+    Server instance cannot be located
+    """