From 0818cb762780115bd9973642e92fcb144811e52d Mon Sep 17 00:00:00 2001 From: Ramakrishnan G Date: Sun, 30 Nov 2014 03:15:29 +0530 Subject: [PATCH] Add VirtualBox drivers and its modules This commit adds VirtualBox modules VirtualBoxPower and VirtualBoxManagement for managing VMs through VirtualBox web service. It also adds two new drivers pxe_vbox and agent_vbox. Implements: blueprint ironic-virtualbox-webservice-support Change-Id: I1e23d21534be30cc4b5a06e998cdce0c5cb04ab6 --- etc/ironic/ironic.conf.sample | 11 + ironic/common/exception.py | 5 + ironic/drivers/agent.py | 29 ++ ironic/drivers/fake.py | 14 + ironic/drivers/modules/virtualbox.py | 367 +++++++++++++++++ ironic/drivers/pxe.py | 24 ++ ironic/tests/drivers/test_virtualbox.py | 374 ++++++++++++++++++ .../tests/drivers/third_party_driver_mocks.py | 10 + setup.cfg | 3 + 9 files changed, 837 insertions(+) create mode 100644 ironic/drivers/modules/virtualbox.py create mode 100644 ironic/tests/drivers/test_virtualbox.py diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index ad7fcb2535..722729ed08 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -1302,3 +1302,14 @@ #swift_max_retries=2 +[virtualbox] + +# +# Options defined in ironic.drivers.modules.virtualbox +# + +# Port on which VirtualBox web service is listening. (integer +# value) +#port=18083 + + diff --git a/ironic/common/exception.py b/ironic/common/exception.py index bc2d361275..6be73d26c2 100755 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -505,3 +505,8 @@ class FileSystemNotSupported(IronicException): class IRMCOperationError(IronicException): message = _('iRMC %(operation)s failed. Reason: %(error)s') + + +class VirtualBoxOperationFailed(IronicException): + message = _("VirtualBox operation '%(operation)s' failed. " + "Error: %(error)s") diff --git a/ironic/drivers/agent.py b/ironic/drivers/agent.py index 0dfea01093..65ab0e568b 100644 --- a/ironic/drivers/agent.py +++ b/ironic/drivers/agent.py @@ -12,11 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. +from oslo.utils import importutils + +from ironic.common import exception +from ironic.common.i18n import _ from ironic.drivers import base from ironic.drivers.modules import agent from ironic.drivers.modules import ipminative from ironic.drivers.modules import ipmitool from ironic.drivers.modules import ssh +from ironic.drivers.modules import virtualbox class AgentAndIPMIToolDriver(base.BaseDriver): @@ -76,3 +81,27 @@ class AgentAndSSHDriver(base.BaseDriver): self.deploy = agent.AgentDeploy() self.management = ssh.SSHManagement() self.vendor = agent.AgentVendorInterface() + + +class AgentAndVirtualBoxDriver(base.BaseDriver): + """Agent + VirtualBox driver. + + NOTE: This driver is meant only for testing environments. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.modules.virtualbox.VirtualBoxPower` (for power + on/off and reboot of VirtualBox virtual machines), 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): + if not importutils.try_import('pyremotevbox'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason=_("Unable to import pyremotevbox library")) + self.power = virtualbox.VirtualBoxPower() + self.deploy = agent.AgentDeploy() + self.management = virtualbox.VirtualBoxManagement() + self.vendor = agent.AgentVendorInterface() diff --git a/ironic/drivers/fake.py b/ironic/drivers/fake.py index c92de86b8d..ac01e0c8cd 100755 --- a/ironic/drivers/fake.py +++ b/ironic/drivers/fake.py @@ -36,6 +36,7 @@ from ironic.drivers.modules import pxe from ironic.drivers.modules import seamicro from ironic.drivers.modules import snmp from ironic.drivers.modules import ssh +from ironic.drivers.modules import virtualbox from ironic.drivers import utils @@ -184,3 +185,16 @@ class FakeIRMCDriver(base.BaseDriver): reason=_("Unable to import python-scciclient library")) self.power = irmc_power.IRMCPower() self.deploy = fake.FakeDeploy() + + +class FakeVirtualBoxDriver(base.BaseDriver): + """Fake VirtualBox driver.""" + + def __init__(self): + if not importutils.try_import('pyremotevbox'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason=_("Unable to import pyremotevbox library")) + self.power = virtualbox.VirtualBoxPower() + self.deploy = fake.FakeDeploy() + self.management = virtualbox.VirtualBoxManagement() diff --git a/ironic/drivers/modules/virtualbox.py b/ironic/drivers/modules/virtualbox.py new file mode 100644 index 0000000000..bb83bc4e7a --- /dev/null +++ b/ironic/drivers/modules/virtualbox.py @@ -0,0 +1,367 @@ +# 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. + +""" +VirtualBox Driver Modules +""" + +from oslo.config import cfg +from oslo.utils import importutils + +from ironic.common import boot_devices +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common.i18n import _LE +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers import base +from ironic.openstack.common import log as logging + +pyremotevbox = importutils.try_import('pyremotevbox') +if pyremotevbox: + from pyremotevbox import exception as virtualbox_exc + from pyremotevbox import vbox as virtualbox + +IRONIC_TO_VIRTUALBOX_DEVICE_MAPPING = { + boot_devices.PXE: 'Network', + boot_devices.DISK: 'HardDisk', + boot_devices.CDROM: 'DVD', + } +VIRTUALBOX_TO_IRONIC_DEVICE_MAPPING = {v: k + for k, v in IRONIC_TO_VIRTUALBOX_DEVICE_MAPPING.items()} + +VIRTUALBOX_TO_IRONIC_POWER_MAPPING = { + 'PoweredOff': states.POWER_OFF, + 'Running': states.POWER_ON, + 'Error': states.ERROR + } + +opts = [ + cfg.IntOpt('port', + default=18083, + help='Port on which VirtualBox web service is listening.'), +] +CONF = cfg.CONF +CONF.register_opts(opts, group='virtualbox') + +LOG = logging.getLogger(__name__) + +REQUIRED_PROPERTIES = { + 'virtualbox_vmname': _("Name of the VM in VirtualBox. Required."), + 'virtualbox_host': _("IP address or hostname of the VirtualBox host. " + "Required.") +} + +OPTIONAL_PROPERTIES = { + 'virtualbox_username': _("Username for the VirtualBox host. " + "Default value is ''. Optional."), + 'virtualbox_password': _("Password for 'virtualbox_username'. " + "Default value is ''. Optional."), + 'virtualbox_port': _("Port on which VirtualBox web service is listening. " + "Optional."), +} + +COMMON_PROPERTIES = REQUIRED_PROPERTIES.copy() +COMMON_PROPERTIES.update(OPTIONAL_PROPERTIES) + + +def _strip_virtualbox_from_param_name(param_name): + + if param_name.startswith('virtualbox_'): + return param_name[11:] + else: + return param_name + + +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. + """ + info = node.driver_info + d_info = {} + + missing_params = [] + for param in REQUIRED_PROPERTIES: + try: + d_info_param_name = _strip_virtualbox_from_param_name(param) + d_info[d_info_param_name] = info[param] + except KeyError: + missing_params.append(param) + + if missing_params: + msg = (_("The following parameters are missing in driver_info: %s") % + ', '.join(missing_params)) + raise exception.MissingParameterValue(msg) + + for param in OPTIONAL_PROPERTIES: + if param in info: + d_info_param_name = _strip_virtualbox_from_param_name(param) + d_info[d_info_param_name] = info[param] + + try: + d_info['port'] = int(d_info.get('port', CONF.virtualbox.port)) + except ValueError: + msg = _("'virtualbox_port' is not an integer.") + raise exception.InvalidParameterValue(msg) + + return d_info + + +def _run_virtualbox_method(node, ironic_method, vm_object_method, + *call_args, **call_kwargs): + """Runs a method of pyremotevbox.vbox.VirtualMachine + + This runs a method from pyremotevbox.vbox.VirtualMachine. + The VirtualMachine method to be invoked and the argument(s) to be + passed to it are to be provided. + + :param node: an Ironic Node object. + :param ironic_method: the Ironic method which called + '_run_virtualbox_method'. This is used for logging only. + :param vm_object_method: The method on the VirtualMachine object + to be called. + :param call_args: The args to be passed to 'vm_object_method'. + :param call_kwargs: The kwargs to be passed to the 'vm_object_method'. + :returns: The value returned by 'vm_object_method' + :raises: VirtualBoxOperationFailed, if execution of 'vm_object_method' + failed. + :raises: InvalidParameterValue, + - if 'vm_object_method' is not a valid 'VirtualMachine' method. + - if some parameter(s) have invalid value(s) in the node's driver_info. + :raises: MissingParameterValue, if some required parameter(s) are missing + in the node's driver_info. + :raises: pyremotevbox.exception.VmInWrongPowerState, if operation cannot + be performed when vm is in the current power state. + """ + driver_info = _parse_driver_info(node) + try: + host = virtualbox.VirtualBoxHost(**driver_info) + vm_object = host.find_vm(driver_info['vmname']) + except virtualbox_exc.PyRemoteVBoxException as exc: + LOG.error(_LE("Failed while creating a VirtualMachine object for " + "node %(node)s. Error: %(error)s."), + {'node_id': node.uuid, 'error': exc}) + raise exception.VirtualBoxOperationFailed(operation=vm_object_method, + error=exc) + + try: + func = getattr(vm_object, vm_object_method) + except AttributeError: + error_msg = _("Invalid VirtualMachine method '%s' passed " + "to '_run_virtualbox_method'.") + raise exception.InvalidParameterValue(error_msg % vm_object_method) + + try: + return func(*call_args, **call_kwargs) + except virtualbox_exc.PyRemoteVBoxException as exc: + error_msg = _LE("'%(ironic_method)s' failed for node %(node_id)s with " + "error: %(error)s.") + LOG.error(error_msg, {'ironic_method': ironic_method, + 'node_id': node.uuid, + 'error': exc}) + raise exception.VirtualBoxOperationFailed(operation=vm_object_method, + error=exc) + + +class VirtualBoxPower(base.PowerInterface): + + def get_properties(self): + return COMMON_PROPERTIES + + def validate(self, task): + """Check if node.driver_info contains the required credentials. + + :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. + """ + _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. + :raises: VirtualBoxOperationFailed, if error encountered from + VirtualBox operation. + """ + power_status = _run_virtualbox_method(task.node, 'get_power_state', + 'get_power_status') + try: + return VIRTUALBOX_TO_IRONIC_POWER_MAPPING[power_status] + except KeyError: + msg = _LE("VirtualBox returned unknown state '%(state)s' for " + "node %(node)s") + LOG.error(msg, {'state': power_status, 'node': task.node.uuid}) + return states.ERROR + + @task_manager.require_exclusive_lock + def set_power_state(self, task, target_state): + """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. + :raises: VirtualBoxOperationFailed, if error encountered from + VirtualBox operation. + """ + if target_state == states.POWER_OFF: + _run_virtualbox_method(task.node, 'set_power_state', 'stop') + elif target_state == states.POWER_ON: + _run_virtualbox_method(task.node, 'set_power_state', 'start') + elif target_state == states.REBOOT: + self.reboot(task) + else: + msg = _("'set_power_state' called with invalid power " + "state '%s'") % target_state + raise exception.InvalidParameterValue(msg) + + @task_manager.require_exclusive_lock + def reboot(self, task): + """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: VirtualBoxOperationFailed, if error encountered from + VirtualBox operation. + """ + _run_virtualbox_method(task.node, 'reboot', 'stop') + _run_virtualbox_method(task.node, 'reboot', 'start') + + +class VirtualBoxManagement(base.ManagementInterface): + + def get_properties(self): + return COMMON_PROPERTIES + + def validate(self, task): + """Check that 'driver_info' contains required credentials. + + 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): + """Get a list of the supported boot devices. + + :returns: A list with the supported boot devices defined + in :mod:`ironic.common.boot_devices`. + """ + return list(IRONIC_TO_VIRTUALBOX_DEVICE_MAPPING.keys()) + + 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: VirtualBoxOperationFailed, if error encountered from + VirtualBox operation. + """ + boot_dev = _run_virtualbox_method(task.node, 'get_boot_device', + 'get_boot_device') + persistent = True + ironic_boot_dev = VIRTUALBOX_TO_IRONIC_DEVICE_MAPPING.get(boot_dev, + None) + if not ironic_boot_dev: + persistent = None + msg = _LE("VirtualBox returned unknown boot device '%(device)s' " + "for node %(node)s") + LOG.error(msg, {'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 as VirtualBox support only + persistent boot devices. + :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: VirtualBoxOperationFailed, if error encountered from + VirtualBox operation. + """ + # NOTE(rameshg87): VirtualBox has only persistent boot devices. + try: + boot_dev = IRONIC_TO_VIRTUALBOX_DEVICE_MAPPING[device] + except KeyError: + raise exception.InvalidParameterValue(_( + "Invalid boot device %s specified.") % device) + + try: + _run_virtualbox_method(task.node, 'set_boot_device', + 'set_boot_device', boot_dev) + except virtualbox_exc.VmInWrongPowerState as exc: + # NOTE(rameshg87): We cannot change the boot device when the vm + # is powered on. This is a VirtualBox limitation. We just log + # the error silently and return because throwing error will cause + # deploys to fail (pxe and agent deploy mechanisms change the boot + # device after completing the deployment, when node is powered on). + # Since this is driver that is meant only for developers, this + # should be okay. Developers will need to set the boot device + # manually after powering off the vm when deployment is complete. + # This will be documented. + LOG.error(_LE("'set_boot_device' failed for node %(node_id)s " + "with error: %(error)s"), + {'node_id': task.node.uuid, 'error': exc}) + + 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/drivers/pxe.py b/ironic/drivers/pxe.py index 877de5a503..c14c1aa2d0 100644 --- a/ironic/drivers/pxe.py +++ b/ironic/drivers/pxe.py @@ -33,6 +33,7 @@ from ironic.drivers.modules import pxe from ironic.drivers.modules import seamicro from ironic.drivers.modules import snmp from ironic.drivers.modules import ssh +from ironic.drivers.modules import virtualbox from ironic.drivers import utils @@ -209,3 +210,26 @@ class PXEAndIRMCDriver(base.BaseDriver): self.deploy = pxe.PXEDeploy() self.management = ipmitool.IPMIManagement() self.vendor = pxe.VendorPassthru() + + +class PXEAndVirtualBoxDriver(base.BaseDriver): + """PXE + VirtualBox driver. + + NOTE: This driver is meant only for testing environments. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.virtualbox.VirtualBoxPower` for power on/off and + reboot of VirtualBox virtual machines, with :class:`ironic.driver.pxe.PXE` + for image deployment. Implementations are in those respective classes; + this class is merely the glue between them. + """ + + def __init__(self): + if not importutils.try_import('pyremotevbox'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason=_("Unable to import pyremotevbox library")) + self.power = virtualbox.VirtualBoxPower() + self.deploy = pxe.PXEDeploy() + self.management = virtualbox.VirtualBoxManagement() + self.vendor = pxe.VendorPassthru() diff --git a/ironic/tests/drivers/test_virtualbox.py b/ironic/tests/drivers/test_virtualbox.py new file mode 100644 index 0000000000..b3dffddac0 --- /dev/null +++ b/ironic/tests/drivers/test_virtualbox.py @@ -0,0 +1,374 @@ +# 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 VirtualBox Driver Modules.""" + +import mock +from oslo.config import cfg +from pyremotevbox import exception as pyremotevbox_exc +from pyremotevbox import vbox as pyremotevbox_vbox + +from ironic.common import boot_devices +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers.modules import virtualbox +from ironic.tests.conductor import utils as mgr_utils +from ironic.tests.db import base as db_base +from ironic.tests.objects import utils as obj_utils + +INFO_DICT = { + 'virtualbox_vmname': 'baremetal1', + 'virtualbox_host': '10.0.2.2', + 'virtualbox_username': 'username', + 'virtualbox_password': 'password', + 'virtualbox_port': 12345, + } + +CONF = cfg.CONF + + +class VirtualBoxMethodsTestCase(db_base.DbTestCase): + + def setUp(self): + super(VirtualBoxMethodsTestCase, self).setUp() + driver_info = INFO_DICT.copy() + mgr_utils.mock_the_extension_manager(driver="fake_vbox") + self.node = obj_utils.create_test_node(self.context, + driver='fake_vbox', + driver_info=driver_info) + + def test__parse_driver_info(self): + info = virtualbox._parse_driver_info(self.node) + self.assertEqual('baremetal1', info['vmname']) + self.assertEqual('10.0.2.2', info['host']) + self.assertEqual('username', info['username']) + self.assertEqual('password', info['password']) + self.assertEqual(12345, info['port']) + + def test__parse_driver_info_missing_vmname(self): + del self.node.driver_info['virtualbox_vmname'] + self.assertRaises(exception.MissingParameterValue, + virtualbox._parse_driver_info, self.node) + + def test__parse_driver_info_missing_host(self): + del self.node.driver_info['virtualbox_host'] + self.assertRaises(exception.MissingParameterValue, + virtualbox._parse_driver_info, self.node) + + def test__parse_driver_info_invalid_port(self): + self.node.driver_info['virtualbox_port'] = 'invalid-port' + self.assertRaises(exception.InvalidParameterValue, + virtualbox._parse_driver_info, self.node) + + def test__parse_driver_info_missing_port(self): + del self.node.driver_info['virtualbox_port'] + info = virtualbox._parse_driver_info(self.node) + self.assertEqual(18083, info['port']) + + @mock.patch.object(pyremotevbox_vbox, 'VirtualBoxHost') + def test__run_virtualbox_method(self, host_mock): + host_object_mock = mock.MagicMock() + func_mock = mock.MagicMock() + vm_object_mock = mock.MagicMock(foo=func_mock) + host_mock.return_value = host_object_mock + host_object_mock.find_vm.return_value = vm_object_mock + func_mock.return_value = 'return-value' + + return_value = virtualbox._run_virtualbox_method(self.node, + 'some-ironic-method', 'foo', 'args', kwarg='kwarg') + + host_mock.assert_called_once_with(vmname='baremetal1', + host='10.0.2.2', + username='username', + password='password', + port=12345) + host_object_mock.find_vm.assert_called_once_with('baremetal1') + func_mock.assert_called_once_with('args', kwarg='kwarg') + self.assertEqual('return-value', return_value) + + @mock.patch.object(pyremotevbox_vbox, 'VirtualBoxHost') + def test__run_virtualbox_method_get_host_fails(self, host_mock): + host_mock.side_effect = pyremotevbox_exc.PyRemoteVBoxException + + self.assertRaises(exception.VirtualBoxOperationFailed, + virtualbox._run_virtualbox_method, + self.node, 'some-ironic-method', 'foo', + 'args', kwarg='kwarg') + + @mock.patch.object(pyremotevbox_vbox, 'VirtualBoxHost') + def test__run_virtualbox_method_find_vm_fails(self, host_mock): + host_object_mock = mock.MagicMock() + host_mock.return_value = host_object_mock + exc = pyremotevbox_exc.PyRemoteVBoxException + host_object_mock.find_vm.side_effect = exc + + self.assertRaises(exception.VirtualBoxOperationFailed, + virtualbox._run_virtualbox_method, + self.node, 'some-ironic-method', 'foo', 'args', + kwarg='kwarg') + host_mock.assert_called_once_with(vmname='baremetal1', + host='10.0.2.2', + username='username', + password='password', + port=12345) + host_object_mock.find_vm.assert_called_once_with('baremetal1') + + @mock.patch.object(pyremotevbox_vbox, 'VirtualBoxHost') + def test__run_virtualbox_method_func_fails(self, host_mock): + host_object_mock = mock.MagicMock() + host_mock.return_value = host_object_mock + func_mock = mock.MagicMock() + vm_object_mock = mock.MagicMock(foo=func_mock) + host_object_mock.find_vm.return_value = vm_object_mock + func_mock.side_effect = pyremotevbox_exc.PyRemoteVBoxException + + self.assertRaises(exception.VirtualBoxOperationFailed, + virtualbox._run_virtualbox_method, + self.node, 'some-ironic-method', 'foo', + 'args', kwarg='kwarg') + host_mock.assert_called_once_with(vmname='baremetal1', + host='10.0.2.2', + username='username', + password='password', + port=12345) + host_object_mock.find_vm.assert_called_once_with('baremetal1') + func_mock.assert_called_once_with('args', kwarg='kwarg') + + @mock.patch.object(pyremotevbox_vbox, 'VirtualBoxHost') + def test__run_virtualbox_method_invalid_method(self, host_mock): + host_object_mock = mock.MagicMock() + host_mock.return_value = host_object_mock + vm_object_mock = mock.MagicMock() + host_object_mock.find_vm.return_value = vm_object_mock + del vm_object_mock.foo + + self.assertRaises(exception.InvalidParameterValue, + virtualbox._run_virtualbox_method, + self.node, 'some-ironic-method', 'foo', + 'args', kwarg='kwarg') + host_mock.assert_called_once_with(vmname='baremetal1', + host='10.0.2.2', + username='username', + password='password', + port=12345) + host_object_mock.find_vm.assert_called_once_with('baremetal1') + + @mock.patch.object(pyremotevbox_vbox, 'VirtualBoxHost') + def test__run_virtualbox_method_vm_wrong_power_state(self, host_mock): + host_object_mock = mock.MagicMock() + host_mock.return_value = host_object_mock + func_mock = mock.MagicMock() + vm_object_mock = mock.MagicMock(foo=func_mock) + host_object_mock.find_vm.return_value = vm_object_mock + func_mock.side_effect = pyremotevbox_exc.VmInWrongPowerState + + # _run_virtualbox_method() doesn't catch VmInWrongPowerState and + # lets caller handle it. + self.assertRaises(pyremotevbox_exc.VmInWrongPowerState, + virtualbox._run_virtualbox_method, + self.node, 'some-ironic-method', 'foo', + 'args', kwarg='kwarg') + host_mock.assert_called_once_with(vmname='baremetal1', + host='10.0.2.2', + username='username', + password='password', + port=12345) + host_object_mock.find_vm.assert_called_once_with('baremetal1') + func_mock.assert_called_once_with('args', kwarg='kwarg') + + +class VirtualBoxPowerTestCase(db_base.DbTestCase): + + def setUp(self): + super(VirtualBoxPowerTestCase, self).setUp() + driver_info = INFO_DICT.copy() + mgr_utils.mock_the_extension_manager(driver="fake_vbox") + self.node = obj_utils.create_test_node(self.context, + driver='fake_vbox', + driver_info=driver_info) + + def test_get_properties(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + properties = task.driver.power.get_properties() + + self.assertIn('virtualbox_vmname', properties) + self.assertIn('virtualbox_host', properties) + + @mock.patch.object(virtualbox, '_parse_driver_info') + def test_validate(self, parse_info_mock): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.power.validate(task) + parse_info_mock.assert_called_once_with(task.node) + + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_get_power_state(self, run_method_mock): + run_method_mock.return_value = 'PoweredOff' + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + power_state = task.driver.power.get_power_state(task) + run_method_mock.assert_called_once_with(task.node, + 'get_power_state', + 'get_power_status') + self.assertEqual(states.POWER_OFF, power_state) + + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_get_power_state_invalid_state(self, run_method_mock): + run_method_mock.return_value = 'invalid-state' + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + power_state = task.driver.power.get_power_state(task) + run_method_mock.assert_called_once_with(task.node, + 'get_power_state', + 'get_power_status') + self.assertEqual(states.ERROR, power_state) + + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_set_power_state_off(self, run_method_mock): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.power.set_power_state(task, states.POWER_OFF) + run_method_mock.assert_called_once_with(task.node, + 'set_power_state', + 'stop') + + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_set_power_state_on(self, run_method_mock): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.power.set_power_state(task, states.POWER_ON) + run_method_mock.assert_called_once_with(task.node, + 'set_power_state', + 'start') + + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_set_power_state_reboot(self, run_method_mock): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.power.set_power_state(task, states.REBOOT) + run_method_mock.assert_any_call(task.node, + 'reboot', + 'stop') + run_method_mock.assert_any_call(task.node, + 'reboot', + 'start') + + def test_set_power_state_invalid_state(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.assertRaises(exception.InvalidParameterValue, + task.driver.power.set_power_state, + task, 'invalid-state') + + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_reboot(self, run_method_mock): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.power.reboot(task) + run_method_mock.assert_any_call(task.node, + 'reboot', + 'stop') + run_method_mock.assert_any_call(task.node, + 'reboot', + 'start') + + +class VirtualBoxManagementTestCase(db_base.DbTestCase): + + def setUp(self): + super(VirtualBoxManagementTestCase, self).setUp() + driver_info = INFO_DICT.copy() + mgr_utils.mock_the_extension_manager(driver="fake_vbox") + self.node = obj_utils.create_test_node(self.context, + driver='fake_vbox', + driver_info=driver_info) + + def test_get_properties(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + properties = task.driver.management.get_properties() + + self.assertIn('virtualbox_vmname', properties) + self.assertIn('virtualbox_host', properties) + + @mock.patch.object(virtualbox, '_parse_driver_info') + def test_validate(self, parse_info_mock): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.management.validate(task) + parse_info_mock.assert_called_once_with(task.node) + + def test_get_supported_boot_devices(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + devices = task.driver.management.get_supported_boot_devices() + self.assertIn(boot_devices.PXE, devices) + self.assertIn(boot_devices.DISK, devices) + self.assertIn(boot_devices.CDROM, devices) + + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_get_boot_device_ok(self, run_method_mock): + run_method_mock.return_value = 'Network' + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + ret_val = task.driver.management.get_boot_device(task) + run_method_mock.assert_called_once_with(task.node, + 'get_boot_device', + 'get_boot_device') + self.assertEqual(boot_devices.PXE, ret_val['boot_device']) + self.assertTrue(ret_val['persistent']) + + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_get_boot_device_invalid(self, run_method_mock): + run_method_mock.return_value = 'invalid-boot-device' + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + ret_val = task.driver.management.get_boot_device(task) + self.assertIsNone(ret_val['boot_device']) + self.assertIsNone(ret_val['persistent']) + + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_set_boot_device_ok(self, run_method_mock): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.management.set_boot_device(task, boot_devices.PXE) + run_method_mock.assert_called_once_with(task.node, + 'set_boot_device', + 'set_boot_device', + 'Network') + + @mock.patch.object(virtualbox, 'LOG') + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_set_boot_device_wrong_power_state(self, run_method_mock, + log_mock): + run_method_mock.side_effect = pyremotevbox_exc.VmInWrongPowerState + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.management.set_boot_device(task, boot_devices.PXE) + log_mock.error.assert_called_once_with(mock.ANY, mock.ANY) + + @mock.patch.object(virtualbox, '_run_virtualbox_method') + def test_set_boot_device_invalid(self, run_method_mock): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.assertRaises(exception.InvalidParameterValue, + task.driver.management.set_boot_device, + task, 'invalid-boot-device') + + def test_get_sensors_data(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.assertRaises(NotImplementedError, + task.driver.management.get_sensors_data, + task) diff --git a/ironic/tests/drivers/third_party_driver_mocks.py b/ironic/tests/drivers/third_party_driver_mocks.py index 40cda34829..8206ccde23 100755 --- a/ironic/tests/drivers/third_party_driver_mocks.py +++ b/ironic/tests/drivers/third_party_driver_mocks.py @@ -161,3 +161,13 @@ if not scciclient: # external library has been mocked if 'ironic.drivers.modules.irmc' in sys.modules: reload(sys.modules['ironic.drivers.modules.irmc']) + +pyremotevbox = importutils.try_import('pyremotevbox') +if not pyremotevbox: + pyremotevbox = mock.MagicMock() + pyremotevbox.exception = mock.MagicMock() + pyremotevbox.exception.PyRemoteVBoxException = Exception + pyremotevbox.exception.VmInWrongPowerState = Exception + sys.modules['pyremotevbox'] = pyremotevbox + if 'ironic.drivers.modules.virtualbox' in sys.modules: + reload(sys.modules['ironic.drivers.modules.virtualbox']) diff --git a/setup.cfg b/setup.cfg index 174b5a231d..994eac77a2 100755 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,7 @@ ironic.drivers = agent_ipmitool = ironic.drivers.agent:AgentAndIPMIToolDriver agent_pyghmi = ironic.drivers.agent:AgentAndIPMINativeDriver agent_ssh = ironic.drivers.agent:AgentAndSSHDriver + agent_vbox = ironic.drivers.agent:AgentAndVirtualBoxDriver fake = ironic.drivers.fake:FakeDriver fake_agent = ironic.drivers.fake:FakeAgentDriver fake_ipmitool = ironic.drivers.fake:FakeIPMIToolDriver @@ -50,10 +51,12 @@ ironic.drivers = fake_drac = ironic.drivers.fake:FakeDracDriver fake_snmp = ironic.drivers.fake:FakeSNMPDriver fake_irmc = ironic.drivers.fake:FakeIRMCDriver + fake_vbox = ironic.drivers.fake:FakeVirtualBoxDriver iscsi_ilo = ironic.drivers.ilo:IloVirtualMediaIscsiDriver pxe_ipmitool = ironic.drivers.pxe:PXEAndIPMIToolDriver pxe_ipminative = ironic.drivers.pxe:PXEAndIPMINativeDriver pxe_ssh = ironic.drivers.pxe:PXEAndSSHDriver + pxe_vbox = ironic.drivers.pxe:PXEAndVirtualBoxDriver pxe_seamicro = ironic.drivers.pxe:PXEAndSeaMicroDriver pxe_iboot = ironic.drivers.pxe:PXEAndIBootDriver pxe_ilo = ironic.drivers.pxe:PXEAndIloDriver