From 296a45c8af92ebd0b31b16b7434d11d0f5f9026b Mon Sep 17 00:00:00 2001 From: vsaienko Date: Fri, 18 Mar 2016 18:03:03 +0200 Subject: [PATCH] Introduce libvirt power/mgmt driver Libvirt has its own API. It allows to connect to different hypervisors like xen, vmware, virtualbox, qemu, full list can be found at https://libvirt.org/drivers.html. It supports different type of transports like ssh, tcp, unix sockets. This patch introduces new type of power and management drivers, which use libvirt-python library to connect to hypervisor. Change-Id: I2df214aab95c2f5d2505f5ad4ef9f3a542e44c6a Depends-On: I12211db38a3fdb3b2d733e5769f2c052c32c4a75 Closes-Bug: #1523880 --- driver-requirements.txt | 6 + ironic_staging_drivers/common/exception.py | 4 + ironic_staging_drivers/libvirt/__init__.py | 68 ++ ironic_staging_drivers/libvirt/power.py | 519 ++++++++++++++ .../tests/unit/libvirt/__init__.py | 0 .../tests/unit/libvirt/test_power.py | 651 ++++++++++++++++++ ...libvirt_power_driver-ab73daee0feb555f.yaml | 7 + setup.cfg | 3 + test-requirements.txt | 3 + 9 files changed, 1261 insertions(+) create mode 100644 driver-requirements.txt create mode 100644 ironic_staging_drivers/libvirt/__init__.py create mode 100644 ironic_staging_drivers/libvirt/power.py create mode 100644 ironic_staging_drivers/tests/unit/libvirt/__init__.py create mode 100644 ironic_staging_drivers/tests/unit/libvirt/test_power.py create mode 100644 releasenotes/notes/libvirt_power_driver-ab73daee0feb555f.yaml diff --git a/driver-requirements.txt b/driver-requirements.txt new file mode 100644 index 0000000..bb80469 --- /dev/null +++ b/driver-requirements.txt @@ -0,0 +1,6 @@ +# This file lists all python libraries which are utilized by drivers, +# and may not be listed in global-requirements. + + +# libvirt driver requires libvirt-python library which is available on pypi +libvirt-python>=1.2.5 # LGPLv2+ diff --git a/ironic_staging_drivers/common/exception.py b/ironic_staging_drivers/common/exception.py index f8f6829..e2f37e9 100644 --- a/ironic_staging_drivers/common/exception.py +++ b/ironic_staging_drivers/common/exception.py @@ -29,3 +29,7 @@ class AMTConnectFailure(exception.IronicException): class AMTFailure(exception.IronicException): _msg_fmt = _("AMT call failed: %(cmd)s.") + + +class LibvirtError(exception.IronicException): + message = _("Libvirt call failed: %(err)s.") diff --git a/ironic_staging_drivers/libvirt/__init__.py b/ironic_staging_drivers/libvirt/__init__.py new file mode 100644 index 0000000..6a7c934 --- /dev/null +++ b/ironic_staging_drivers/libvirt/__init__.py @@ -0,0 +1,68 @@ +# 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. + +from ironic.drivers import base +from ironic.drivers.modules import agent +from ironic.drivers.modules import fake +from ironic.drivers.modules import iscsi_deploy +from ironic.drivers.modules import pxe +from ironic_staging_drivers.libvirt import power + + +class FakeLibvirtFakeDriver(base.BaseDriver): + """Example implementation of a Driver.""" + + def __init__(self): + self.power = power.LibvirtPower() + self.deploy = fake.FakeDeploy() + self.management = power.LibvirtManagement() + + +class PXELibvirtAgentDriver(base.BaseDriver): + """PXE + Agent + Libvirt driver. + + NOTE: This driver is meant only for testing environments. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.modules.power.LibvirtPower` (for power on/off and + reboot of virtual machines tunneled over Libvirt API), with + :class:`ironic.drivers.modules.agent.AgentDeploy` (for image + deployment). Implementations are in those respective classes; this class + is merely the glue between them. + """ + + def __init__(self): + self.power = power.LibvirtPower() + self.boot = pxe.PXEBoot() + self.deploy = agent.AgentDeploy() + self.management = power.LibvirtManagement() + self.vendor = agent.AgentVendorInterface() + self.raid = agent.AgentRAID() + + +class PXELibvirtISCSIDriver(base.BaseDriver): + """PXE + Libvirt + iSCSI driver. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.modules.pxe.PXEBoot` for boot and + :class:`ironic_staging_drivers.libvirt.LibvirtPower` for power on/off and + :class:`ironic.drivers.modules.iscsi_deploy.ISCSIDeploy` for image + deployment. Implementations are in those respective classes; this + class is merely the glue between them. + """ + + def __init__(self): + self.power = power.LibvirtPower() + self.boot = pxe.PXEBoot() + self.deploy = iscsi_deploy.ISCSIDeploy() + self.management = power.LibvirtManagement() + self.vendor = iscsi_deploy.VendorPassthru() diff --git a/ironic_staging_drivers/libvirt/power.py b/ironic_staging_drivers/libvirt/power.py new file mode 100644 index 0000000..379db41 --- /dev/null +++ b/ironic_staging_drivers/libvirt/power.py @@ -0,0 +1,519 @@ +# Copyright (c) 2015 Mirantis, Inc. +# 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. + +""" +Ironic Libvirt power manager and management interface. + +Provides basic power control and management of virtual machines +via Libvirt API. + +For use in dev and test environments. + +Currently supported environments are: + Virtual Box + Virsh + VMware WS/ESX/Player + XenServer + OpenVZ + Microsoft Hyper-V + Virtuozzo + +Currently supported transports are: + unix (open auth) + tcp (SASL auth) + tls (SASL auth) + ssh (SSH Key auth) + +""" + +import os +import xml.etree.ElementTree as ET + +import libvirt +from oslo_config import cfg +from oslo_log import log as logging + +from ironic.common import boot_devices +from ironic.common import exception as ir_exc +from ironic.common.i18n import _ +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers import base +from ironic.drivers import utils as driver_utils +from ironic_staging_drivers.common import exception as isd_exc + + +CONF = cfg.CONF + +LOG = logging.getLogger(__name__) + +DEFAULT_URI = 'qemu+unix:///system' +REQUIRED_PROPERTIES = {} +OTHER_PROPERTIES = { + 'libvirt_uri': _("libvirt URI, default is qemu+unix:///system. Optional."), + 'sasl_username': _("username to authenticate as. Optional."), + 'sasl_password': _("password to use for SASL authentication. Optional."), + 'ssh_key_filename': _("filename of private key " + "for authentication. Optional.") +} + +COMMON_PROPERTIES = REQUIRED_PROPERTIES.copy() +COMMON_PROPERTIES.update(OTHER_PROPERTIES) + + +_BOOT_DEVICES_MAP = { + boot_devices.DISK: 'hd', + boot_devices.PXE: 'network', + boot_devices.CDROM: 'cdrom', +} + + +def _get_libvirt_connection(driver_info): + """Get the libvirt connection. + + :param driver_info: driver info + :returns: the active libvirt connection + :raises: LibvirtError if failed to connect to the Libvirt uri. + """ + + uri = driver_info.get('libvirt_uri') or DEFAULT_URI + sasl_username = driver_info.get('sasl_username') + sasl_password = driver_info.get('sasl_password') + ssh_key_filename = driver_info.get('ssh_key_filename') + + try: + if sasl_username and sasl_password: + def request_cred(credentials, user_data): + for credential in credentials: + if credential[0] == libvirt.VIR_CRED_AUTHNAME: + credential[4] = sasl_username + elif credential[0] == libvirt.VIR_CRED_PASSPHRASE: + credential[4] = sasl_password + return 0 + auth = [[libvirt.VIR_CRED_AUTHNAME, libvirt.VIR_CRED_PASSPHRASE], + request_cred, None] + conn = libvirt.openAuth(uri, auth, 0) + elif ssh_key_filename: + uri += "?keyfile=%s&no_verify=1" % ssh_key_filename + conn = libvirt.open(uri) + else: + conn = libvirt.open(uri) + except libvirt.libvirtError as e: + raise isd_exc.LibvirtError(err=e) + + if conn is None: + raise isd_exc.LibvirtError( + err=_("Failed to open connection to %s") % uri) + return conn + + +def _get_domain_by_macs(task): + """Get the domain the host uses to reference the node. + + :param task: a TaskManager instance containing the node to act on + :returns: the libvirt domain object. + :raises: NodeNotFound if could not find a VM corresponding to any + of the provided MACs. + :raises: InvalidParameterValue if any connection parameters are + incorrect or if failed to connect to the Libvirt uri. + :raises: LibvirtError if failed to connect to the Libvirt uri. + """ + + driver_info = _parse_driver_info(task.node) + conn = _get_libvirt_connection(driver_info) + macs = driver_utils.get_node_mac_addresses(task) + node_macs = {driver_utils.normalize_mac(mac) + for mac in macs} + + full_node_list = conn.listAllDomains() + + for domain in full_node_list: + LOG.debug("Checking Domain: %s's Mac address", domain.name()) + parsed = ET.fromstring(domain.XMLDesc()) + domain_macs = {driver_utils.normalize_mac( + el.attrib['address']) for el in parsed.iter('mac')} + + found_macs = domain_macs & node_macs # this is intersection of sets + if found_macs: + LOG.debug("Found MAC addresses: %s " + "for node: %s", found_macs, driver_info['uuid']) + return domain + + raise ir_exc.NodeNotFound( + _("Can't find domain with specified MACs: %(macs)s " + "for node %(node)s") % + {'macs': domain_macs, 'node': driver_info['uuid']}) + + +def _parse_driver_info(node): + """Gets the information needed for accessing the node. + + :param node: the Node of interest. + :returns: dictionary of information. + :raises: MissingParameterValue if any required parameters are missing. + :raises: InvalidParameterValue if any required parameters are incorrect. + """ + + info = node.driver_info or {} + missing_info = [key for key in REQUIRED_PROPERTIES if not info.get(key)] + if missing_info: + raise ir_exc.MissingParameterValue(_( + "LibvirtPowerDriver requires the following parameters to be set in" + "node's driver_info: %s.") % missing_info) + + uri = info.get('libvirt_uri') or DEFAULT_URI + sasl_username = info.get('sasl_username') + sasl_password = info.get('sasl_password') + ssh_key_filename = info.get('ssh_key_filename') + + if sasl_username and sasl_password and ssh_key_filename: + raise ir_exc.InvalidParameterValue(_( + "LibvirtPower requires one and only one of the authentication, " + "(sasl_username, sasl_password) or ssh_key_filename to be set.")) + + if ssh_key_filename and not os.path.isfile(ssh_key_filename): + raise ir_exc.InvalidParameterValue(_( + "SSH key file %s not found.") % ssh_key_filename) + + res = { + 'libvirt_uri': uri, + 'uuid': node.uuid, + 'sasl_username': sasl_username, + 'sasl_password': sasl_password, + 'ssh_key_filename': ssh_key_filename, + } + + return res + + +def _power_on(domain): + """Power ON this domain. + + :param domain: libvirt domain object. + :returns: one of ironic.common.states POWER_ON or ERROR. + :raises: LibvirtError if failed to connect to start domain. + """ + + current_pstate = _get_power_state(domain) + if current_pstate == states.POWER_ON: + return current_pstate + + try: + domain.create() + except libvirt.libvirtError as e: + raise isd_exc.LibvirtError(err=e) + + current_pstate = _get_power_state(domain) + if current_pstate == states.POWER_ON: + return current_pstate + else: + return states.ERROR + + +def _power_off(domain): + """Power OFF this domain. + + :param domain: libvirt domain object. + :returns: one of ironic.common.states POWER_OFF or ERROR. + :raises: LibvirtError if failed to destroy domain. + """ + + current_pstate = _get_power_state(domain) + if current_pstate == states.POWER_OFF: + return current_pstate + + try: + domain.destroy() + except libvirt.libvirtError as e: + raise isd_exc.LibvirtError(err=e) + + current_pstate = _get_power_state(domain) + if current_pstate == states.POWER_OFF: + return current_pstate + else: + return states.ERROR + + +def _power_cycle(domain): + """Power cycles a node. + + :param domain: libvirt domain object. + :raises: PowerStateFailure if it failed to set power state to POWER_ON. + :raises: LibvirtError if failed to power cycle domain. + """ + + try: + _power_off(domain) + state = _power_on(domain) + except libvirt.libvirtError as e: + raise isd_exc.LibvirtError(err=e) + + if state != states.POWER_ON: + raise ir_exc.PowerStateFailure(pstate=states.POWER_ON) + + +def _get_power_state(domain): + """Get the current power state of domain. + + :param domain: libvirt domain object. + :returns: power state. One of :class:`ironic.common.states`. + :raises: LibvirtErr if failed to get doamin status. + """ + + try: + if domain.isActive(): + return states.POWER_ON + except libvirt.libvirtError as e: + raise isd_exc.LibvirtError(err=e) + + return states.POWER_OFF + + +def _get_boot_device(domain): + """Get the current boot device. + + :param domain: libvirt domain object. + :returns: boot device. + """ + + boot_element = ET.fromstring(domain.XMLDesc()).find('.//os/boot') + boot_dev = None + if boot_element is not None: + boot_dev = boot_element.attrib.get('dev') + + return boot_dev + + +def _set_boot_device(conn, domain, device): + """Set the boot device. + + :param conn: active libvirt connection. + :param domain: libvirt domain object. + :raises: LibvirtError if failed update domain xml. + """ + + parsed = ET.fromstring(domain.XMLDesc()) + os = parsed.find('os') + boot_list = os.findall('boot') + + # Clear boot list + for boot_el in boot_list: + os.remove(boot_el) + + boot_el = ET.SubElement(os, 'boot') + boot_el.set('dev', device) + + try: + conn.defineXML(ET.tostring(parsed)) + except libvirt.libvirtError as e: + raise isd_exc.LibvirtError(err=e) + + +class LibvirtPower(base.PowerInterface): + """Libvirt Power Interface. + + This PowerInterface class provides a mechanism for controlling the power + state of virtual machines via libvirt. + + """ + + def get_properties(self): + return COMMON_PROPERTIES + + def validate(self, task): + """Check that the node's 'driver_info' is valid. + + Check that the node's 'driver_info' contains the requisite fields + and that an Libvirt connection to the node can be established. + + :param task: a TaskManager instance containing the node to act on. + :raises: InvalidParameterValue if any connection parameters are + incorrect or if failed to connect to the libvirt socket. + :raises: MissingParameterValue if no ports are enrolled for the given + node. + """ + + if not driver_utils.get_node_mac_addresses(task): + raise ir_exc.MissingParameterValue( + _("Node %s does not have any ports associated with it" + ) % task.node.uuid) + + def get_power_state(self, task): + """Get the current power state of the task's node. + + Poll the host for the current power state of the task's node. + + :param task: a TaskManager instance containing the node to act on. + :returns: power state. One of :class:`ironic.common.states`. + :raises: InvalidParameterValue if any connection parameters are + incorrect. + :raises: NodeNotFound if could not find a VM corresponding to any + of the provided MACs. + :raises: LibvirtError if failed to connect to the Libvirt uri. + """ + + domain = _get_domain_by_macs(task) + return _get_power_state(domain) + + @task_manager.require_exclusive_lock + def set_power_state(self, task, pstate): + """Turn the power on or off. + + Set the power state of the task's node. + + :param task: a TaskManager instance containing the node to act on. + :param pstate: Either POWER_ON or POWER_OFF from :class: + `ironic.common.states`. + :raises: InvalidParameterValue if any connection parameters are + incorrect, or if the desired power state is invalid. + :raises: MissingParameterValue when a required parameter is missing + :raises: NodeNotFound if could not find a VM corresponding to any + of the provided MACs. + :raises: PowerStateFailure if it failed to set power state to pstate. + :raises: LibvirtError if failed to connect to the Libvirt uri. + """ + + domain = _get_domain_by_macs(task) + if pstate == states.POWER_ON: + state = _power_on(domain) + elif pstate == states.POWER_OFF: + state = _power_off(domain) + else: + raise ir_exc.InvalidParameterValue( + _("set_power_state called with invalid power state %s." + ) % pstate) + + if state != pstate: + raise ir_exc.PowerStateFailure(pstate=pstate) + + @task_manager.require_exclusive_lock + def reboot(self, task): + """Cycles the power to the task's node. + + Power cycles a node. + + :param task: a TaskManager instance containing the node to act on. + :raises: InvalidParameterValue if any connection parameters are + incorrect. + :raises: MissingParameterValue when a required parameter is missing + :raises: NodeNotFound if could not find a VM corresponding to any + of the provided MACs. + :raises: PowerStateFailure if it failed to set power state to POWER_ON. + :raises: LibvirtError if failed to connect to the Libvirt uri. + """ + + domain = _get_domain_by_macs(task) + + _power_cycle(domain) + + state = _get_power_state(domain) + + if state != states.POWER_ON: + raise ir_exc.PowerStateFailure(pstate=states.POWER_ON) + + +class LibvirtManagement(base.ManagementInterface): + + def get_properties(self): + return COMMON_PROPERTIES + + def validate(self, task): + """Check that 'driver_info' contains Libvirt URI. + + Validates whether the 'driver_info' property of the supplied + task's node contains the required credentials information. + + :param task: a task from TaskManager. + :raises: MissingParameterValue if a required parameter is missing + """ + + _parse_driver_info(task.node) + + def get_supported_boot_devices(self, task): + """Get a list of the supported boot devices. + + :param task: a task from TaskManager. + :returns: A list with the supported boot devices defined + in :mod:`ironic.common.boot_devices`. + """ + + return list(_BOOT_DEVICES_MAP.keys()) + + @task_manager.require_exclusive_lock + def set_boot_device(self, task, device, persistent=False): + """Set the boot device for the task's node. + + Set the boot device to use on next reboot of the node. + + :param task: a task from TaskManager. + :param device: the boot device, one of + :mod:`ironic.common.boot_devices`. + :param persistent: Boolean value. True if the boot device will + persist to all future boots, False if not. + Default: False. Ignored by this driver. + :raises: InvalidParameterValue if an invalid boot device is + specified or if any connection parameters are incorrect. + :raises: MissingParameterValue if a required parameter is missing + :raises: NodeNotFound if could not find a VM corresponding to any + of the provided MACs. + :raises: LibvirtError if failed to connect to the Libvirt uri. + """ + + domain = _get_domain_by_macs(task) + driver_info = _parse_driver_info(task.node) + conn = _get_libvirt_connection(driver_info) + if device not in self.get_supported_boot_devices(task): + raise ir_exc.InvalidParameterValue(_( + "Invalid boot device %s specified.") % device) + + boot_device_map = _BOOT_DEVICES_MAP + _set_boot_device(conn, domain, boot_device_map[device]) + + def get_boot_device(self, task): + """Get the current boot device for the task's node. + + Provides the current boot device of the node. Be aware that not + all drivers support this. + + :param task: a task from TaskManager. + :raises: InvalidParameterValue if any connection parameters are + incorrect. + :raises: MissingParameterValue if a required parameter is missing + :raises: NodeNotFound if could not find a VM corresponding to any + of the provided MACs. + :returns: a dictionary containing: + :boot_device: the boot device, one of + :mod:`ironic.common.boot_devices` or None if it is unknown. + :persistent: Whether the boot device will persist to all + future boots or not, None if it is unknown. + :raises: LibvirtError if failed to connect to the Libvirt uri. + """ + + domain = _get_domain_by_macs(task) + + response = {'boot_device': None, 'persistent': None} + response['boot_device'] = _get_boot_device(domain) + return response + + def get_sensors_data(self, task): + """Get sensors data. + + Not implemented by this driver. + + :param task: a TaskManager instance. + + """ + + raise NotImplementedError() diff --git a/ironic_staging_drivers/tests/unit/libvirt/__init__.py b/ironic_staging_drivers/tests/unit/libvirt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ironic_staging_drivers/tests/unit/libvirt/test_power.py b/ironic_staging_drivers/tests/unit/libvirt/test_power.py new file mode 100644 index 0000000..cfa84ac --- /dev/null +++ b/ironic_staging_drivers/tests/unit/libvirt/test_power.py @@ -0,0 +1,651 @@ +# Copyright (c) 2016 Mirantis, Inc. +# 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. + +"""Test class for Ironic libvirt driver.""" + + +import tempfile + +import mock + +from ironic.common import boot_devices +from ironic.common import driver_factory +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers import utils as driver_utils +from ironic_staging_drivers.common import exception as isd_exc +from ironic_staging_drivers.libvirt import power + +from ironic.tests.unit.conductor import mgr_utils +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as obj_utils + + +def _get_test_libvirt_driver_info(auth_type='ssh_key'): + if auth_type == 'ssh_key': + return { + 'libvirt_uri': 'qemu+ssh://test@test/', + 'ssh_key_filename': '/test/key/file' + } + elif auth_type == 'sasl': + return { + 'libvirt_uri': 'test+tcp://localhost:5000/test', + 'sasl_username': 'admin', + 'sasl_password': 'admin' + } + elif auth_type == 'no_uri': + return {'ssh_key_filename': '/test/key/file'} + elif auth_type == 'socket': + return {'libvirt_uri': 'qemu+unix:///system?' + 'socket=/opt/libvirt/run/libvirt-sock'} + + return{ + 'libvirt_uri': 'qemu+ssh://test@test/', + 'ssh_key_filename': '/test/key/file', + 'sasl_username': 'admin', + 'sasl_password': 'admin' + } + + +class FakeLibvirtDomain(object): + def __init__(self, uuid=None): + self.uuid = uuid + + def name(self): + return 'test_libvirt_domain' + + def XMLDesc(self, boot_dev=power._BOOT_DEVICES_MAP[boot_devices.PXE]): + return( + """ + test_libvirt_domain + 1be26c0b-03f2-4d2e-ae87-c02d7f33c123 + /usr/bin/pygrub + + hvm + + + + 512000 + 1 + destroy + restart + restart + + + + +