diff --git a/ironic_staging_drivers/common/exception.py b/ironic_staging_drivers/common/exception.py index bb1ebe8..da3990f 100644 --- a/ironic_staging_drivers/common/exception.py +++ b/ironic_staging_drivers/common/exception.py @@ -37,3 +37,7 @@ class LibvirtError(exception.IronicException): class InvalidIPMITimestamp(exception.IronicException): pass + + +class oVirtError(exception.IronicException): + message = _("oVirt call failed: %(err)s.") diff --git a/ironic_staging_drivers/ovirt/__init__.py b/ironic_staging_drivers/ovirt/__init__.py new file mode 100644 index 0000000..eb75f9c --- /dev/null +++ b/ironic_staging_drivers/ovirt/__init__.py @@ -0,0 +1,37 @@ +# -*- encoding: utf-8 -*- +# +# 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. +""" +PXE Driver and supporting meta-classes. +""" + +from ironic.drivers import generic + +from ironic_staging_drivers.ovirt import ovirt + + +class oVirtHardware(generic.GenericHardware): + """oVirt hardware type. + + Uses oVirt for power and management. + """ + + @property + def supported_management_interfaces(self): + """List of supported management interfaces.""" + return [ovirt.oVirtManagement] + + @property + def supported_power_interfaces(self): + """List of supported power interfaces.""" + return [ovirt.oVirtPower] diff --git a/ironic_staging_drivers/ovirt/other-requirements.sh b/ironic_staging_drivers/ovirt/other-requirements.sh new file mode 100644 index 0000000..1e485a6 --- /dev/null +++ b/ironic_staging_drivers/ovirt/other-requirements.sh @@ -0,0 +1,3 @@ +OVIRT_DEB_PACKAGES="libcurl4-openssl-dev libssl-dev libxml2-dev" + +install_package $OVIRT_DEB_PACKAGES diff --git a/ironic_staging_drivers/ovirt/ovirt.py b/ironic_staging_drivers/ovirt/ovirt.py new file mode 100644 index 0000000..d2d83e4 --- /dev/null +++ b/ironic_staging_drivers/ovirt/ovirt.py @@ -0,0 +1,339 @@ +# 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 oVirt power manager and management interface. + +Provides basic power control and management of virtual machines +via oVirt sdk API. + +For use in dev and test environments. +""" + +from ironic.common import boot_devices +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers import base +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import importutils + +from ironic_staging_drivers.common import exception as staging_exception + +ovirtsdk = importutils.try_import('ovirtsdk4') +if ovirtsdk: + import ovirtsdk4 as sdk + import ovirtsdk4.types as otypes + +IRONIC_TO_OVIRT_DEVICE_MAPPING = { + boot_devices.PXE: 'network', + boot_devices.DISK: 'hd', + boot_devices.CDROM: 'cdrom', +} +OVIRT_TO_IRONIC_DEVICE_MAPPING = {v: k for k, v in + IRONIC_TO_OVIRT_DEVICE_MAPPING.items()} + +OVIRT_TO_IRONIC_POWER_MAPPING = { + 'down': states.POWER_OFF, + 'error': states.ERROR, + 'image_locked': states.POWER_OFF, + 'migrating': states.POWER_ON, + 'not_responding': states.ERROR, + 'paused': states.ERROR, + 'powering_down': states.POWER_OFF, + 'powering_up': states.POWER_ON, + 'reboot_in_progress': states.POWER_ON, + 'wait_for_launch': states.POWER_ON, + 'up': states.POWER_ON +} + +opts = [ + cfg.StrOpt('address', + default='127.0.0.1', + help='oVirt address'), + cfg.StrOpt('username', + default='admin@internal', + help='oVirt username'), + cfg.StrOpt('password', + help='oVirt password'), + cfg.StrOpt('insecure', + default=False, + help='Skips verification of the oVirt host certificate'), + cfg.StrOpt('ca_file', + help='oVirt path to a CA file'), +] +CONF = cfg.CONF +CONF.register_opts(opts, group='ovirt') + +LOG = logging.getLogger(__name__) + +PROPERTIES = { + 'ovirt_address': _("Address of the oVirt Manager"), + 'ovirt_username': _("oVirt username"), + 'ovirt_password': _("oVirt password"), + 'ovirt_insecure': _("Skips oVirt host certificate's verification"), + 'ovirt_ca_file': _("oVirt path to a CA file"), + 'ovirt_vm_name': _("Name of the VM in oVirt. Required."), +} + + +def _parse_driver_info(node): + """Gets the driver specific node driver info. + + This method validates whether the 'driver_info' property of the + supplied node contains the required information for this driver. + + :param node: an Ironic Node object. + :returns: a dict containing information from driver_info (or where + applicable, config values). + :raises: MissingParameterValue, if some required parameter(s) are missing + in the node's driver_info. + :raises: InvalidParameterValue, if some parameter(s) have invalid value(s) + in the node's driver_info. + """ + conf_info = {attr: getattr(CONF.ovirt, attr) for attr in CONF.ovirt} + node_info = node.driver_info or {} + driver_info = {} + for prop in PROPERTIES: + node_value = node_info.get(prop) + conf_value = conf_info.get(prop.replace('ovirt_', '')) + value = node_value if node_value is not None else conf_value + if value is None and prop not in ['ovirt_ca_file', 'ovirt_insecure']: + raise exception.MissingParameterValue( + _("%(prop)s is not set either in the configuration or" + "in the node's driver_info")) + else: + driver_info[prop] = value + insecure = driver_info['ovirt_insecure'] + ovirt_ca_file = driver_info['ovirt_ca_file'] + if not insecure and ovirt_ca_file is None: + msg = _("Missing ovirt_ca_file in the node's driver_info") + raise exception.MissingParameterValue(msg) + return driver_info + + +def _getvm(driver_info): + address = driver_info['ovirt_address'] + username = driver_info['ovirt_username'] + password = driver_info['ovirt_password'] + insecure = driver_info['ovirt_insecure'] + ca_file = driver_info['ovirt_ca_file'] + name = driver_info['ovirt_vm_name'].encode('ascii', 'ignore') + url = "https://%s/ovirt-engine/api" % address + try: + connection = sdk.Connection(url=url, username=username, + password=password, insecure=insecure, + ca_file=ca_file) + vms_service = connection.system_service().vms_service() + vmsearch = vms_service.list(search='name=%s' % name) + except sdk.Error as e: + LOG.error("Could not fetch information about VM vm %(name)s, " + "got error: %(error)s", {'name': name, 'error': e}) + raise staging_exception.oVirtError(err=e) + if vmsearch: + return vms_service.vm_service(vmsearch[0].id) + else: + raise staging_exception.oVirtError(_("VM with name " + "%s was not found") % name) + + +class oVirtPower(base.PowerInterface): + + def get_properties(self): + return PROPERTIES + + def validate(self, task): + """Check if node.driver_info contains ovirt_vm_name. + + :param task: a TaskManager instance. + :raises: MissingParameterValue, if some of the required parameters are + missing in the node's driver_info. + :raises: InvalidParameterValue, if some of the parameters have invalid + values in the node's driver_info. + """ + _parse_driver_info(task.node) + + def get_power_state(self, task): + """Gets the current power state. + + :param task: a TaskManager instance. + :returns: one of :mod:`ironic.common.states` + :raises: MissingParameterValue, if some required parameter(s) are + missing in the node's driver_info. + :raises: InvalidParameterValue, if some parameter(s) have invalid + value(s) in the node's driver_info. + """ + driver_info = _parse_driver_info(task.node) + vm_name = driver_info['ovirt_vm_name'] + vm = _getvm(driver_info) + status = vm.get().status.value + if status not in OVIRT_TO_IRONIC_POWER_MAPPING: + msg = ("oVirt returned unknown state for node %(node)s " + "and vm %(vm)s") + LOG.error(msg, {'node': task.node.uuid, 'vm': vm_name}) + return states.ERROR + else: + return OVIRT_TO_IRONIC_POWER_MAPPING[status] + + @task_manager.require_exclusive_lock + def set_power_state(self, task, target_state, timeout=None): + """Turn the current power state on or off. + + :param task: a TaskManager instance. + :param target_state: The desired power state POWER_ON, POWER_OFF or + REBOOT from :mod:`ironic.common.states`. + :raises: MissingParameterValue, if some required parameter(s) are + missing in the node's driver_info. + :raises: InvalidParameterValue, if some parameter(s) have invalid + value(s) in the node's driver_info OR if an invalid power state + was specified. + """ + driver_info = _parse_driver_info(task.node) + vm_name = driver_info['ovirt_vm_name'] + vm = _getvm(driver_info) + try: + if target_state == states.POWER_OFF: + vm.stop() + elif target_state == states.POWER_ON: + vm.start() + elif target_state == states.REBOOT: + vm.reboot() + else: + msg = _("'set_power_state' called with invalid power " + "state '%s'") % target_state + raise exception.InvalidParameterValue(msg) + except sdk.Error as e: + LOG.error("Could not change status of VM vm %(name)s " + "got error: %(error)s", {'name': vm_name, 'error': e}) + raise staging_exception.oVirtError(err=e) + + @task_manager.require_exclusive_lock + def reboot(self, task, timeout=None): + """Reboot the node. + + :param task: a TaskManager instance. + :raises: MissingParameterValue, if some required parameter(s) are + missing in the node's driver_info. + :raises: InvalidParameterValue, if some parameter(s) have invalid + value(s) in the node's driver_info. + :raises: ovirtsdk4.Error, if error encountered from + oVirt operation. + """ + driver_info = _parse_driver_info(task.node) + vm_name = driver_info['ovirt_vm_name'] + vm = _getvm(driver_info) + try: + vm.reboot() + except sdk.Error as e: + LOG.error("Could not restart VM vm %(name)s " + "got error: %(error)s", {'name': vm_name, 'error': e}) + raise staging_exception.oVirtError(err=e) + + +class oVirtManagement(base.ManagementInterface): + + def get_properties(self): + return PROPERTIES + + def validate(self, task): + """Check that 'driver_info' contains ovirt_vm_name. + + 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 some required parameter(s) are + missing in the node's driver_info. + :raises: InvalidParameterValue, if some parameter(s) have invalid + value(s) in the node's driver_info. + """ + _parse_driver_info(task.node) + + def get_supported_boot_devices(self, task): + """Get a list of the supported boot devices. + + :returns: A list with the supported boot devices defined + in :mod:`ironic.common.boot_devices`. + """ + return sorted(list(IRONIC_TO_OVIRT_DEVICE_MAPPING)) + + def get_boot_device(self, task): + """Get the current boot device for a node. + + :param task: a task from TaskManager. + :returns: a dictionary containing: + 'boot_device': one of the ironic.common.boot_devices or None + 'persistent': True if boot device is persistent, False otherwise + :raises: MissingParameterValue, if some required parameter(s) are + missing in the node's driver_info. + :raises: InvalidParameterValue, if some parameter(s) have invalid + value(s) in the node's driver_info. + :raises: oVirtError, if error encountered from + oVirt operation. + """ + driver_info = _parse_driver_info(task.node) + vm = _getvm(driver_info) + boot_dev = vm.os.boot[0].get_dev() + persistent = True + ironic_boot_dev = OVIRT_TO_IRONIC_DEVICE_MAPPING.get(boot_dev) + if not ironic_boot_dev: + persistent = False + msg = _("oVirt returned unknown boot device '%(device)s' " + "for node %(node)s") + LOG.error(msg, {'device': boot_dev, 'node': task.node.uuid}) + raise staging_exception.oVirtError(msg.format(device=boot_dev, + node=task.node.uuid)) + + return {'boot_device': ironic_boot_dev, 'persistent': persistent} + + @task_manager.require_exclusive_lock + def set_boot_device(self, task, device, persistent=False): + """Set the boot device for a node. + + :param task: a task from TaskManager. + :param device: ironic.common.boot_devices + :param persistent: This argument is ignored. + :raises: MissingParameterValue, if some required parameter(s) are + missing in the node's driver_info. + :raises: InvalidParameterValue, if some parameter(s) have invalid + value(s) in the node's driver_info. + """ + try: + boot_dev = IRONIC_TO_OVIRT_DEVICE_MAPPING[device] + except KeyError: + raise exception.InvalidParameterValue(_( + "Invalid boot device %s specified.") % device) + + driver_info = _parse_driver_info(task.node) + vm = _getvm(driver_info) + try: + boot = otypes.Boot(devices=[otypes.BootDevice(boot_dev)]) + bootos = otypes.OperatingSystem(boot=boot) + vm.update(otypes.Vm(os=bootos)) + except sdk.Error as e: + LOG.error("Setting boot device failed for node %(node_id)s " + "with error: %(error)s", + {'node_id': task.node.uuid, 'error': e}) + raise staging_exception.oVirtError(err=e) + + def get_sensors_data(self, task): + """Get sensors data. + + :param task: a TaskManager instance. + :raises: FailedToGetSensorData when getting the sensor data fails. + :raises: FailedToParseSensorData when parsing sensor data fails. + :returns: returns a consistent format dict of sensor data grouped by + sensor type, which can be processed by Ceilometer. + """ + raise NotImplementedError() diff --git a/ironic_staging_drivers/ovirt/python-requirements.txt b/ironic_staging_drivers/ovirt/python-requirements.txt new file mode 100644 index 0000000..9e883f2 --- /dev/null +++ b/ironic_staging_drivers/ovirt/python-requirements.txt @@ -0,0 +1 @@ +ovirt-engine-sdk-python>=4.0.0 # Apache-2.0 diff --git a/ironic_staging_drivers/tests/unit/ovirt/__init__.py b/ironic_staging_drivers/tests/unit/ovirt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ironic_staging_drivers/tests/unit/ovirt/test_ovirt.py b/ironic_staging_drivers/tests/unit/ovirt/test_ovirt.py new file mode 100644 index 0000000..8d4f352 --- /dev/null +++ b/ironic_staging_drivers/tests/unit/ovirt/test_ovirt.py @@ -0,0 +1,112 @@ +# 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 oVirt driver module.""" + +import time + +from ironic.common import boot_devices +from ironic.common import states +from ironic.conductor import task_manager +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 +import mock + +from ironic_staging_drivers.ovirt import ovirt as ovirt_power + + +def _ovirt_info(): + driver_info = {'ovirt_address': '127.0.0.1', + 'ovirt_username': 'jhendrix@internal', + 'ovirt_password': 'changeme', + 'ovirt__insecure': True, + 'ovirt_ca_file': None, + 'ovirt_vm_name': 'jimi'} + return driver_info + + +@mock.patch.object(time, 'sleep', lambda *_: None) +class oVirtDriverTestCase(db_base.DbTestCase): + + def setUp(self): + super(oVirtDriverTestCase, self).setUp() + self.config(enabled_power_interfaces='staging-ovirt', + enabled_management_interfaces='staging-ovirt') + namespace = 'ironic.hardware.types' + mgr_utils.mock_the_extension_manager(driver='staging-ovirt', + namespace=namespace) + self.node = obj_utils.create_test_node(self.context, + driver='staging-ovirt', + driver_info=_ovirt_info()) + self.port = obj_utils.create_test_port(self.context, + node_id=self.node.id) + + def test__parse_parameters(self): + params = ovirt_power._parse_driver_info(self.node) + self.assertEqual('127.0.0.1', params['ovirt_address']) + self.assertEqual('jhendrix@internal', params['ovirt_username']) + self.assertEqual('changeme', params['ovirt_password']) + self.assertEqual('jimi', params['ovirt_vm_name']) + + def test_get_properties(self): + expected = list(ovirt_power.PROPERTIES.keys()) + with task_manager.acquire( + self.context, self.node.uuid, shared=False) as task: + driver_properties = [prop for prop in task.driver.get_properties() + if prop in expected] + self.assertEqual(sorted(expected), sorted(driver_properties)) + + @mock.patch.object(ovirt_power.oVirtPower, 'set_power_state', + autospec=True, spec_set=True) + def test_set_power_state_power_on(self, mock_power): + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.power.set_power_state(task, states.POWER_ON) + mock_power.assert_called_once_with(task.driver.power, task, + states.POWER_ON) + + @mock.patch.object(ovirt_power.oVirtPower, 'set_power_state', + autospec=True, spec_set=True) + def test_set_power_state_power_off(self, mock_power): + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.power.set_power_state(task, states.POWER_OFF) + mock_power.assert_called_once_with(task.driver.power, task, + states.POWER_OFF) + + def test_get_supported_power_states(self): + with task_manager.acquire( + self.context, self.node.uuid, shared=True) as task: + pstates = task.driver.power.get_supported_power_states(task) + self.assertEqual([states.POWER_ON, states.POWER_OFF, + states.REBOOT], pstates) + + def test_get_supported_boot_devices(self): + with task_manager.acquire( + self.context, self.node.uuid, shared=True) as task: + bdevices = task.driver.management.get_supported_boot_devices(task) + self.assertEqual([boot_devices.CDROM, boot_devices.DISK, + boot_devices.PXE], bdevices) + + @mock.patch.object(ovirt_power.oVirtManagement, 'get_boot_device', + return_value='hd') + def test_get_boot_device(self, mock_management): + with task_manager.acquire(self.context, self.node.uuid) as task: + boot_dev = task.driver.management.get_boot_device(task) + self.assertEqual('hd', boot_dev) + + @mock.patch.object(ovirt_power.oVirtManagement, 'set_boot_device', + autospec=True, spec_set=True) + def test_set_boot_device(self, mock_power): + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.management.set_boot_device(task, boot_devices.DISK) + mock_power.assert_called_once_with(task.driver.management, task, + boot_devices.DISK) diff --git a/setup.cfg b/setup.cfg index b806ccb..47ab62a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,11 +49,13 @@ ironic.hardware.interfaces.deploy = ironic.hardware.interfaces.management = staging-amt = ironic_staging_drivers.amt.management:AMTManagement staging-libvirt = ironic_staging_drivers.libvirt.power:LibvirtManagement + staging-ovirt = ironic_staging_drivers.ovirt.ovirt:oVirtManagement ironic.hardware.interfaces.power = staging-amt = ironic_staging_drivers.amt.power:AMTPower staging-iboot = ironic_staging_drivers.iboot.power:IBootPower staging-libvirt = ironic_staging_drivers.libvirt.power:LibvirtPower + staging-ovirt = ironic_staging_drivers.ovirt.ovirt:oVirtPower staging-wol = ironic_staging_drivers.wol.power:WakeOnLanPower ironic.hardware.interfaces.vendor = @@ -65,6 +67,7 @@ ironic.hardware.types = staging-iboot = ironic_staging_drivers.iboot:IBootHardware staging-nm = ironic_staging_drivers.intel_nm:IntelNMHardware staging-libvirt = ironic_staging_drivers.libvirt:LibvirtHardware + staging-ovirt = ironic_staging_drivers.ovirt:oVirtHardware staging-wol = ironic_staging_drivers.wol:WOLHardware [build_sphinx]