1 # Copyright (c) 2017 Cable Television Laboratories, Inc. ("CableLabs")
2 # and others. All rights reserved.
4 # Licensed under the Apache License, Version 2.0 (the "License");
5 # you may not use this file except in compliance with the License.
6 # You may obtain a copy of the License at:
8 # http://www.apache.org/licenses/LICENSE-2.0
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS,
12 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
18 from neutronclient.common.exceptions import PortNotFoundClient
19 from novaclient.exceptions import NotFound, BadRequest
21 from snaps.config.vm_inst import VmInstanceConfig, FloatingIpConfig
22 from snaps.openstack.openstack_creator import OpenStackComputeObject
23 from snaps.openstack.utils import glance_utils, cinder_utils, settings_utils
24 from snaps.openstack.utils import neutron_utils
25 from snaps.openstack.utils import nova_utils
26 from snaps.provisioning import ansible_utils
28 __author__ = 'spisarski'
30 logger = logging.getLogger('create_instance')
33 STATUS_ACTIVE = 'ACTIVE'
34 STATUS_DELETED = 'DELETED'
37 class OpenStackVmInstance(OpenStackComputeObject):
39 Class responsible for managing a VM instance in OpenStack
42 def __init__(self, os_creds, instance_settings, image_settings,
43 keypair_settings=None):
46 :param os_creds: The connection credentials to the OpenStack API
47 :param instance_settings: Contains the settings for this VM
48 :param image_settings: The OpenStack image object settings
49 :param keypair_settings: The keypair metadata (Optional)
52 super(self.__class__, self).__init__(os_creds)
56 self.instance_settings = instance_settings
57 self.image_settings = image_settings
58 self.keypair_settings = keypair_settings
60 self.__floating_ip_dict = dict()
62 # Instantiated in self.create()
65 # Note: this object does not change after the VM becomes active
70 Loads the existing VMInst, Port, FloatingIps
71 :return: VMInst domain object
73 super(self.__class__, self).initialize()
75 self.__neutron = neutron_utils.neutron_client(self._os_creds)
77 self.__ports = self.__query_ports(self.instance_settings.port_settings)
78 self.__lookup_existing_vm_by_name()
80 def create(self, block=False):
82 Creates a VM instance and associated objects unless they already exist
83 :param block: Thread will block until instance has either become
84 active, error, or timeout waiting.
85 Additionally, when True, floating IPs will not be applied
87 :return: VMInst domain object
91 if len(self.__ports) == 0:
92 self.__ports = self.__create_ports(
93 self.instance_settings.port_settings)
95 self.__create_vm(block)
99 def __lookup_existing_vm_by_name(self):
101 Populates the member variables 'self.vm' and 'self.floating_ips' if a
102 VM with the same name already exists
105 server = nova_utils.get_server(
106 self._nova, vm_inst_settings=self.instance_settings)
108 if server.name == self.instance_settings.name:
111 'Found existing machine with name - %s',
112 self.instance_settings.name)
114 fips = neutron_utils.get_floating_ips(self.__neutron,
116 for port_id, fip in fips:
117 settings = self.instance_settings.floating_ip_settings
118 for fip_setting in settings:
119 if port_id == fip_setting.port_id:
120 self.__floating_ip_dict[fip_setting.name] = fip
122 port = neutron_utils.get_port_by_id(
123 self.__neutron, port_id)
124 if port and port.name == fip_setting.port_name:
125 self.__floating_ip_dict[fip_setting.name] = fip
127 def __create_vm(self, block=False):
129 Responsible for creating the VM instance
130 :param block: Thread will block until instance has either become
131 active, error, or timeout waiting. Floating IPs will be
132 assigned after active when block=True
134 glance = glance_utils.glance_client(self._os_creds)
135 self.__vm = nova_utils.create_server(
136 self._nova, self.__neutron, glance, self.instance_settings,
137 self.image_settings, self.keypair_settings)
138 logger.info('Created instance with name - %s',
139 self.instance_settings.name)
142 if not self.vm_active(block=True):
143 raise VmInstanceCreationError(
144 'Fatal error, VM did not become ACTIVE within the alloted '
147 # Create server should do this but found it needed to occur here
148 for sec_grp_name in self.instance_settings.security_group_names:
149 if self.vm_active(block=True):
150 nova_utils.add_security_group(self._nova, self.__vm,
153 raise VmInstanceCreationError(
154 'Cannot applying security group with name ' +
156 ' to VM that did not activate with name - ' +
157 self.instance_settings.name)
159 if self.instance_settings.volume_names:
160 for volume_name in self.instance_settings.volume_names:
161 cinder = cinder_utils.cinder_client(self._os_creds)
162 volume = cinder_utils.get_volume(
163 cinder, volume_name=volume_name)
165 if volume and self.vm_active(block=True):
167 vm = nova_utils.attach_volume(
168 self._nova, self.__vm, volume, timeout)
173 logger.warn('Volume [%s] not attached within timeout '
174 'of [%s]', volume.name, timeout)
176 logger.warn('Unable to attach volume named [%s]',
179 self.__apply_floating_ips()
181 def __apply_floating_ips(self):
183 Applies the configured floating IPs to the necessary ports
186 for key, port in self.__ports:
187 port_dict[key] = port
190 for floating_ip_setting in self.instance_settings.floating_ip_settings:
191 self.add_floating_ip(floating_ip_setting)
193 def add_floating_ip(self, floating_ip_setting):
195 Adds a floating IP to a running instance
196 :param floating_ip_setting - the floating IP configuration
199 for key, port in self.__ports:
200 port_dict[key] = port
203 port = port_dict.get(floating_ip_setting.port_name)
206 raise VmInstanceCreationError(
207 'Cannot find port object with name - ' +
208 floating_ip_setting.port_name)
210 # Setup Floating IP only if there is a router with an external
212 ext_gateway = self.__ext_gateway_by_router(
213 floating_ip_setting.router_name)
215 subnet = neutron_utils.get_subnet(
217 subnet_name=floating_ip_setting.subnet_name)
218 floating_ip = neutron_utils.create_floating_ip(
219 self.__neutron, ext_gateway)
220 self.__floating_ip_dict[floating_ip_setting.name] = floating_ip
223 'Created floating IP %s via router - %s', floating_ip.ip,
224 floating_ip_setting.router_name)
225 self.__add_floating_ip(floating_ip, port, subnet)
227 raise VmInstanceCreationError(
228 'Unable to add floating IP to port, cannot locate router '
229 'with an external gateway ')
231 def __ext_gateway_by_router(self, router_name):
233 Returns network name for the external network attached to a router or
235 :param router_name: The name of the router to lookup
236 :return: the external network name or None
238 router = neutron_utils.get_router(
239 self.__neutron, router_name=router_name)
240 if router and router.external_network_id:
241 network = neutron_utils.get_network_by_id(
242 self.__neutron, router.external_network_id)
249 Destroys the VM instance
252 # Cleanup floating IPs
253 for name, floating_ip in self.__floating_ip_dict.items():
255 logger.info('Deleting Floating IP - ' + floating_ip.ip)
256 neutron_utils.delete_floating_ip(self.__neutron, floating_ip)
257 except Exception as e:
258 logger.error('Error deleting Floating IP - ' + str(e))
259 self.__floating_ip_dict = dict()
262 for volume_rec in self.__vm.volume_ids:
263 cinder = cinder_utils.cinder_client(self._os_creds)
264 volume = cinder_utils.get_volume_by_id(cinder, volume_rec['id'])
267 vm = nova_utils.detach_volume(
268 self._nova, self.__vm, volume, 30)
273 'Timeout waiting to detach volume %s', volume.name)
274 except Exception as e:
275 logger.error('Unexpected error detaching volume %s '
276 'with error %s', volume.name, e)
278 logger.warn('Unable to detach volume with ID - [%s]',
282 for name, port in self.__ports:
283 logger.info('Deleting Port with ID - %s ', port.id)
285 neutron_utils.delete_port(self.__neutron, port)
286 except PortNotFoundClient as e:
287 logger.warning('Unexpected error deleting port - %s', e)
289 self.__ports = list()
295 'Deleting VM instance - ' + self.instance_settings.name)
296 nova_utils.delete_vm_instance(self._nova, self.__vm)
297 except Exception as e:
298 logger.error('Error deleting VM - %s', e)
300 # Block until instance cannot be found or returns the status of
302 logger.info('Checking deletion status')
305 if self.vm_deleted(block=True):
307 'VM has been properly deleted VM with name - %s',
308 self.instance_settings.name)
312 'VM not deleted within the timeout period of %s '
313 'seconds', self.instance_settings.vm_delete_timeout)
314 except Exception as e:
316 'Unexpected error while checking VM instance status - %s',
319 def __query_ports(self, port_settings):
321 Returns the previously configured ports or an empty list if none
323 :param port_settings: A list of PortSetting objects
324 :return: a list of OpenStack port tuples where the first member is the
325 port name and the second is the port object
329 for port_setting in port_settings:
330 port = neutron_utils.get_port(
331 self.__neutron, port_settings=port_setting)
333 ports.append((port_setting.name, port))
337 def __create_ports(self, port_settings):
339 Returns the previously configured ports or creates them if they do not
341 :param port_settings: A list of PortSetting objects
342 :return: a list of OpenStack port tuples where the first member is the
343 port name and the second is the port object
347 for port_setting in port_settings:
348 port = neutron_utils.get_port(
349 self.__neutron, port_settings=port_setting)
351 port = neutron_utils.create_port(
352 self.__neutron, self._os_creds, port_setting)
354 ports.append((port_setting.name, port))
358 def __add_floating_ip(self, floating_ip, port, subnet, timeout=30,
359 poll_interval=POLL_INTERVAL):
361 Returns True when active else False
362 TODO - Make timeout and poll_interval configurable...
367 # Take IP of subnet if there is one configured on which to place
369 for fixed_ip in port.ips:
370 if fixed_ip['subnet_id'] == subnet.id:
371 ip = fixed_ip['ip_address']
374 # Simply take the first
375 ip = port.ips[0]['ip_address']
378 count = timeout / poll_interval
380 logger.debug('Attempting to add floating IP to instance')
382 nova_utils.add_floating_ip_to_server(
383 self._nova, self.__vm, floating_ip, ip)
385 'Added floating IP %s to port IP %s on instance %s',
386 floating_ip.ip, ip, self.instance_settings.name)
388 except BadRequest as bre:
389 logger.error('Cannot add floating IP [%s]', bre)
391 except Exception as e:
393 'Retry adding floating IP to instance. Last attempt '
394 'failed with - %s', e)
395 time.sleep(poll_interval)
399 raise VmInstanceCreationError(
400 'Unable find IP address on which to place the floating IP')
402 logger.error('Timeout attempting to add the floating IP to instance.')
403 raise VmInstanceCreationError(
404 'Timeout while attempting add floating IP to instance')
406 def get_os_creds(self):
408 Returns the OpenStack credentials used to create these objects
409 :return: the credentials
411 return self._os_creds
413 def get_vm_inst(self):
415 Returns the latest version of this server object from OpenStack
416 :return: Server object
418 return nova_utils.get_server_object_by_id(self._nova, self.__vm.id)
420 def get_console_output(self):
422 Returns the vm console object for parsing logs
423 :return: the console output object
425 return nova_utils.get_server_console_output(self._nova, self.__vm)
427 def get_port_ip(self, port_name, subnet_name=None):
429 Returns the first IP for the port corresponding with the port_name
430 parameter when subnet_name is None else returns the IP address that
431 corresponds to the subnet_name parameter
432 :param port_name: the name of the port from which to return the IP
433 :param subnet_name: the name of the subnet attached to this IP
434 :return: the IP or None if not found
436 port = self.get_port_by_name(port_name)
439 subnet = neutron_utils.get_subnet(
440 self.__neutron, subnet_name=subnet_name)
442 logger.warning('Cannot retrieve port IP as subnet could '
443 'not be located with name - %s',
446 for fixed_ip in port.ips:
447 if fixed_ip['subnet_id'] == subnet.id:
448 return fixed_ip['ip_address']
450 if port.ips and len(port.ips) > 0:
451 return port.ips[0]['ip_address']
454 def get_port_mac(self, port_name):
456 Returns the first IP for the port corresponding with the port_name
458 TODO - Add in the subnet as an additional parameter as a port may have
460 :param port_name: the name of the port from which to return the IP
461 :return: the IP or None if not found
463 port = self.get_port_by_name(port_name)
465 return port.mac_address
468 def get_port_by_name(self, port_name):
470 Retrieves the OpenStack port object by its given name
471 :param port_name: the name of the port
472 :return: the OpenStack port object or None if not exists
474 for key, port in self.__ports:
477 logger.warning('Cannot find port with name - ' + port_name)
480 def get_vm_info(self):
482 Returns a dictionary of a VMs info as returned by OpenStack
485 return nova_utils.get_server_info(self._nova, self.__vm)
487 def config_nics(self):
489 Responsible for configuring NICs on RPM systems where the instance has
490 more than one configured port
491 :return: the value returned by ansible_utils.apply_ansible_playbook()
493 if len(self.__ports) > 1 and len(self.__floating_ip_dict) > 0:
494 if self.vm_active(block=True) and self.vm_ssh_active(block=True):
495 for key, port in self.__ports:
496 port_index = self.__ports.index((key, port))
498 nic_name = 'eth' + repr(port_index)
499 retval = self.__config_nic(
501 self.__get_first_provisioning_floating_ip().ip)
502 logger.info('Configured NIC - %s on VM - %s',
503 nic_name, self.instance_settings.name)
506 def __get_first_provisioning_floating_ip(self):
508 Returns the first floating IP tagged with the Floating IP name if
509 exists else the first one found
512 for floating_ip_setting in self.instance_settings.floating_ip_settings:
513 if floating_ip_setting.provisioning:
514 fip = self.__floating_ip_dict.get(floating_ip_setting.name)
517 elif len(self.__floating_ip_dict) > 0:
518 for key, fip in self.__floating_ip_dict.items():
521 # When cannot be found above
522 if len(self.__floating_ip_dict) > 0:
523 for key, fip in self.__floating_ip_dict.items():
526 def __config_nic(self, nic_name, port, ip):
528 Although ports/NICs can contain multiple IPs, this code currently only
531 :param nic_name: Name of the interface
532 :param port: The port information containing the expected IP values.
533 :param ip: The IP on which to apply the playbook.
534 :return: the return value from ansible
536 port_ip = port.ips[0]['ip_address']
539 'nic_name': nic_name,
543 if self.image_settings.nic_config_pb_loc and self.keypair_settings:
544 return self.apply_ansible_playbook(
545 self.image_settings.nic_config_pb_loc, variables)
548 'VM %s cannot self configure NICs eth1++. No playbook or '
549 'keypairs found.', self.instance_settings.name)
551 def apply_ansible_playbook(self, pb_file_loc, variables=None,
554 Applies a playbook to a VM
555 :param pb_file_loc: the file location of the playbook to be applied
556 :param variables: a dict() of substitution values required by the
558 :param fip_name: the name of the floating IP to use for applying the
559 playbook (default - will take the first)
560 :return: the return value from ansible
562 return ansible_utils.apply_playbook(
563 pb_file_loc, [self.get_floating_ip(fip_name=fip_name).ip],
564 self.get_image_user(), self.keypair_settings.private_filepath,
565 variables, self._os_creds.proxy_settings)
567 def get_image_user(self):
569 Returns the instance sudo_user if it has been configured in the
570 instance_settings else it returns the image_settings.image_user value
572 if self.instance_settings.sudo_user:
573 return self.instance_settings.sudo_user
575 return self.image_settings.image_user
577 def vm_deleted(self, block=False, poll_interval=POLL_INTERVAL):
579 Returns true when the VM status returns the value of
580 expected_status_code or instance retrieval throws a NotFound exception.
581 :param block: When true, thread will block until active or timeout
582 value in seconds has been exceeded (False)
583 :param poll_interval: The polling interval in seconds
587 return self.__vm_status_check(
588 STATUS_DELETED, block,
589 self.instance_settings.vm_delete_timeout, poll_interval)
590 except NotFound as e:
592 "Instance not found when querying status for %s with message "
593 "%s", STATUS_DELETED, e)
596 def vm_active(self, block=False, poll_interval=POLL_INTERVAL):
598 Returns true when the VM status returns the value of the constant
600 :param block: When true, thread will block until active or timeout
601 value in seconds has been exceeded (False)
602 :param poll_interval: The polling interval in seconds
605 if self.__vm_status_check(
606 STATUS_ACTIVE, block, self.instance_settings.vm_boot_timeout,
608 self.__vm = nova_utils.get_server_object_by_id(
609 self._nova, self.__vm.id)
613 def __vm_status_check(self, expected_status_code, block, timeout,
616 Returns true when the VM status returns the value of
618 :param expected_status_code: instance status evaluated with this
620 :param block: When true, thread will block until active or timeout
621 value in seconds has been exceeded (False)
622 :param timeout: The timeout value
623 :param poll_interval: The polling interval in seconds
626 # sleep and wait for VM status change
630 return self.__status(expected_status_code)
632 while timeout > time.time() - start:
633 status = self.__status(expected_status_code)
635 logger.info('VM is - ' + expected_status_code)
638 logger.debug('Retry querying VM status in ' + str(
639 poll_interval) + ' seconds')
640 time.sleep(poll_interval)
641 logger.debug('VM status query timeout in ' + str(
642 timeout - (time.time() - start)))
645 'Timeout checking for VM status for ' + expected_status_code)
648 def __status(self, expected_status_code):
650 Returns True when active else False
651 :param expected_status_code: instance status evaluated with this string
656 if expected_status_code == STATUS_DELETED:
661 status = nova_utils.get_server_status(self._nova, self.__vm)
663 logger.warning('Cannot find instance with id - ' + self.__vm.id)
666 if status == 'ERROR':
667 raise VmInstanceCreationError(
668 'Instance had an error during deployment')
670 'Instance status [%s] is - %s', self.instance_settings.name,
672 return status == expected_status_code
674 def vm_ssh_active(self, block=False, poll_interval=POLL_INTERVAL):
676 Returns true when the VM can be accessed via SSH
677 :param block: When true, thread will block until active or timeout
678 value in seconds has been exceeded (False)
679 :param poll_interval: The polling interval
682 # sleep and wait for VM status change
683 logger.info('Checking if VM is active')
685 timeout = self.instance_settings.ssh_connect_timeout
687 if self.vm_active(block=True):
691 start = time.time() - timeout
693 while timeout > time.time() - start:
694 status = self.__ssh_active()
696 logger.info('SSH is active for VM instance')
699 logger.debug('Retry SSH connection in ' + str(
700 poll_interval) + ' seconds')
701 time.sleep(poll_interval)
702 logger.debug('SSH connection timeout in ' + str(
703 timeout - (time.time() - start)))
705 logger.error('Timeout attempting to connect with VM via SSH')
708 def __ssh_active(self):
710 Returns True when can create a SSH session else False
713 if len(self.__floating_ip_dict) > 0:
714 ssh = self.ssh_client()
720 def get_floating_ip(self, fip_name=None):
722 Returns the floating IP object byt name if found, else the first known,
724 :param fip_name: the name of the floating IP to return
725 :return: the SSH client or None
727 if fip_name and self.__floating_ip_dict.get(fip_name):
728 return self.__floating_ip_dict.get(fip_name)
730 return self.__get_first_provisioning_floating_ip()
732 def ssh_client(self, fip_name=None):
734 Returns an SSH client using the name or the first known floating IP if
736 :param fip_name: the name of the floating IP to return
737 :return: the SSH client or None
739 fip = self.get_floating_ip(fip_name)
741 return ansible_utils.ssh_client(
742 self.__get_first_provisioning_floating_ip().ip,
743 self.get_image_user(),
744 self.keypair_settings.private_filepath,
745 proxy_settings=self._os_creds.proxy_settings)
747 FloatingIPAllocationError(
748 'Cannot return an SSH client. No Floating IP configured')
750 def add_security_group(self, security_group):
752 Adds a security group to this VM. Call will block until VM is active.
753 :param security_group: the SNAPS SecurityGroup domain object
754 :return True if successful else False
756 self.vm_active(block=True)
758 if not security_group:
759 logger.warning('Security group object is None, cannot add')
763 nova_utils.add_security_group(self._nova, self.get_vm_inst(),
766 except NotFound as e:
767 logger.warning('Security group not added - ' + str(e))
770 def remove_security_group(self, security_group):
772 Removes a security group to this VM. Call will block until VM is active
773 :param security_group: the OpenStack security group object
774 :return True if successful else False
776 self.vm_active(block=True)
778 if not security_group:
779 logger.warning('Security group object is None, cannot remove')
783 nova_utils.remove_security_group(self._nova, self.get_vm_inst(),
786 except NotFound as e:
787 logger.warning('Security group not removed - ' + str(e))
791 def generate_creator(os_creds, vm_inst, image_config, keypair_config=None):
793 Initializes an OpenStackVmInstance object
794 :param os_creds: the OpenStack credentials
795 :param vm_inst: the SNAPS-OO VmInst domain object
796 :param image_config: the associated ImageConfig object
797 :param keypair_config: the associated KeypairConfig object (optional)
798 :return: an initialized OpenStackVmInstance object
800 nova = nova_utils.nova_client(os_creds)
801 neutron = neutron_utils.neutron_client(os_creds)
802 derived_inst_config = settings_utils.create_vm_inst_config(
803 nova, neutron, vm_inst)
805 derived_inst_creator = OpenStackVmInstance(
806 os_creds, derived_inst_config, image_config, keypair_config)
807 derived_inst_creator.initialize()
808 return derived_inst_creator
811 class VmInstanceSettings(VmInstanceConfig):
813 Deprecated, use snaps.config.vm_inst.VmInstanceConfig instead
815 def __init__(self, **kwargs):
816 from warnings import warn
817 warn('Use snaps.config.vm_inst.VmInstanceConfig instead',
819 super(self.__class__, self).__init__(**kwargs)
822 class FloatingIpSettings(FloatingIpConfig):
824 Deprecated, use snaps.config.vm_inst.FloatingIpConfig instead
826 def __init__(self, **kwargs):
827 from warnings import warn
828 warn('Use snaps.config.vm_inst.FloatingIpConfig instead',
830 super(self.__class__, self).__init__(**kwargs)
833 class VmInstanceCreationError(Exception):
835 Exception to be thrown when an VM instance cannot be created
839 class FloatingIPAllocationError(Exception):
841 Exception to be thrown when an VM instance cannot allocate a floating IP