diff --git a/driver-requirements.txt b/driver-requirements.txt index beb5421862..e3ea2d2d37 100644 --- a/driver-requirements.txt +++ b/driver-requirements.txt @@ -18,3 +18,6 @@ ansible>=2.4 # HUAWEI iBMC hardware type uses the python-ibmcclient library python-ibmcclient>=0.1.0 + +# Dell EMC iDRAC sushy OEM extension +sushy-oem-idrac<=0.1.0 diff --git a/ironic/drivers/drac.py b/ironic/drivers/drac.py index 73afa57d39..2c6a1e6e5a 100644 --- a/ironic/drivers/drac.py +++ b/ironic/drivers/drac.py @@ -18,13 +18,16 @@ DRAC Driver for remote system management using Dell Remote Access Card. from oslo_config import cfg from ironic.drivers import generic +from ironic.drivers.modules.drac import boot from ironic.drivers.modules.drac import inspect as drac_inspect from ironic.drivers.modules.drac import management from ironic.drivers.modules.drac import power from ironic.drivers.modules.drac import raid from ironic.drivers.modules.drac import vendor_passthru from ironic.drivers.modules import inspector +from ironic.drivers.modules import ipxe from ironic.drivers.modules import noop +from ironic.drivers.modules import pxe CONF = cfg.CONF @@ -35,6 +38,11 @@ class IDRACHardware(generic.GenericHardware): # Required hardware interfaces + @property + def supported_boot_interfaces(self): + """List of supported boot interfaces.""" + return [ipxe.iPXEBoot, pxe.PXEBoot, boot.DracRedfishVirtualMediaBoot] + @property def supported_management_interfaces(self): """List of supported management interfaces.""" diff --git a/ironic/drivers/modules/drac/boot.py b/ironic/drivers/modules/drac/boot.py new file mode 100644 index 0000000000..771dc7f5ec --- /dev/null +++ b/ironic/drivers/modules/drac/boot.py @@ -0,0 +1,161 @@ +# Copyright 2019 Red Hat, Inc. +# All Rights Reserved. +# Copyright (c) 2019 Dell Inc. or its subsidiaries. +# +# 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 oslo_log import log +from oslo_utils import importutils + +from ironic.common import boot_devices +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.drivers.modules.redfish import boot as redfish_boot +from ironic.drivers.modules.redfish import utils as redfish_utils + +LOG = log.getLogger(__name__) + +sushy = importutils.try_import('sushy') + + +class DracRedfishVirtualMediaBoot(redfish_boot.RedfishVirtualMediaBoot): + """iDRAC Redfish interface for virtual media boot-related actions. + + Virtual Media allows booting the system from "virtual" + CD/DVD drive containing user image that BMC "inserts" + into the drive. + + The CD/DVD images must be in ISO format and (depending on + BMC implementation) could be pulled over HTTP, served as + iSCSI targets or NFS volumes. + + The baseline boot workflow is mostly based on the standard + Redfish virtual media boot interface, which looks like + this: + + 1. Pull kernel, ramdisk and ESP if UEFI boot is requested (FAT partition + image with EFI boot loader) images + 2. Create bootable ISO out of images (#1), push it to Glance and + pass to the BMC as Swift temporary URL + 3. Optionally create floppy image with desired system configuration data, + push it to Glance and pass to the BMC as Swift temporary URL + 4. Insert CD/DVD and (optionally) floppy images and set proper boot mode + + For building deploy or rescue ISO, redfish boot interface uses + `deploy_kernel`/`deploy_ramdisk` or `rescue_kernel`/`rescue_ramdisk` + properties from `[instance_info]` or `[driver_info]`. + + For building boot (user) ISO, redfish boot interface seeks `kernel_id` + and `ramdisk_id` properties in the Glance image metadata found in + `[instance_info]image_source` node property. + + iDRAC virtual media boot interface only differs by the way how it + sets the node to boot from a virtual media device - this is done + via OEM action call implemented in Dell sushy OEM extension package. + """ + + if sushy: + VIRTUAL_MEDIA_DEVICES = { + boot_devices.FLOPPY: sushy.VIRTUAL_MEDIA_FLOPPY, + boot_devices.CDROM: sushy.VIRTUAL_MEDIA_CD + } + + @classmethod + def _set_boot_device(cls, task, device, persistent=False): + """Set boot device for a node. + + Dell iDRAC Redfish implementation does not support setting + boot device to virtual media via standard Redfish means. + Instead, Dell BMC sets boot device to local physical CD/floppy. + However, it is still feasible to boot from a virtual media + device by invoking Dell OEM extension. + + :param task: a TaskManager instance. + :param device: the boot device, one of + :mod:`ironic.common.boot_devices`. + :param persistent: Whether to set next-boot, or make the change + permanent. Default: False. + :raises: InvalidParameterValue if the validation of the + ManagementInterface fails. + """ + # NOTE(etingof): always treat CD/floppy as virtual + if device not in cls.VIRTUAL_MEDIA_DEVICES: + LOG.debug( + 'Treating boot device %(device)s as a non-virtual ' + 'media device for node %(node)s', + {'device': device, 'node': task.node.uuid}) + super(DracRedfishVirtualMediaBoot, cls)._set_boot_device( + task, device, persistent) + return + + device = cls.VIRTUAL_MEDIA_DEVICES[device] + + system = redfish_utils.get_system(task.node) + + for manager in system.managers: + + # This call makes Sushy go fishing in the ocean of Sushy + # OEM extensions installed on the system. If it finds one + # for 'Dell' which implements the 'Manager' resource + # extension, it uses it to create an object which + # instantiates itself from the OEM JSON. The object is + # returned here. + # + # If the extension could not be found for one manager, it + # will not be found for any others until it is installed, so + # abruptly exit the for loop. The vendor and resource name, + # 'Dell' and 'Manager', respectively, used to search for the + # extension are invariant in the loop. + try: + manager_oem = manager.get_oem_extension('Dell') + except sushy.exceptions.OEMExtensionNotFoundError as e: + error_msg = (_("Search for Sushy OEM extension Python package " + "'sushy-oem-idrac' failed for node %(node)s. " + "Ensure it is installed. Error: %(error)s") % + {'node': task.node.uuid, 'error': e}) + LOG.error(error_msg) + raise exception.RedfishError(error=error_msg) + + try: + manager_oem.set_virtual_boot_device( + device, persistent=persistent, manager=manager, + system=system) + except sushy.exceptions.SushyError as e: + LOG.debug("Sushy OEM extension Python package " + "'sushy-oem-idrac' failed to set virtual boot " + "device with system %(system)s manager %(manager)s " + "for node %(node)s. Will try next manager, if " + "available. Error: %(error)s", + {'system': system.uuid if system.uuid else + system.identity, + 'manager': manager.uuid if manager.uuid else + manager.identity, + 'node': task.node.uuid, + 'error': e}) + continue + + LOG.info("Set node %(node)s boot device to %(device)s via OEM", + {'node': task.node.uuid, 'device': device}) + break + + else: + error_msg = (_('iDRAC Redfish set boot device failed for node ' + '%(node)s, because system %(system)s has no ' + 'manager%(no_manager)s.') % + {'node': task.node.uuid, + 'system': system.uuid if system.uuid else + system.identity, + 'no_manager': '' if not system.managers else + ' which could'}) + LOG.error(error_msg) + raise exception.RedfishError(error=error_msg) diff --git a/ironic/tests/unit/drivers/modules/drac/test_boot.py b/ironic/tests/unit/drivers/modules/drac/test_boot.py new file mode 100644 index 0000000000..ec8ae4fe68 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/drac/test_boot.py @@ -0,0 +1,167 @@ +# Copyright 2019 Red Hat, Inc. +# All Rights Reserved. +# Copyright (c) 2019 Dell Inc. or its subsidiaries. +# +# 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 DRAC boot interface +""" + +import mock +from oslo_utils import importutils + +from ironic.common import boot_devices +from ironic.common import exception +from ironic.conductor import task_manager +from ironic.drivers.modules.drac import boot as drac_boot +from ironic.tests.unit.drivers.modules.drac import utils as test_utils +from ironic.tests.unit.objects import utils as obj_utils + +sushy = importutils.try_import('sushy') + +INFO_DICT = test_utils.INFO_DICT + + +@mock.patch.object(drac_boot, 'redfish_utils', autospec=True) +class DracBootTestCase(test_utils.BaseDracTest): + + def setUp(self): + super(DracBootTestCase, self).setUp() + self.node = obj_utils.create_test_node( + self.context, driver='idrac', driver_info=INFO_DICT) + + def test__set_boot_device_persistent(self, mock_redfish_utils): + + mock_system = mock_redfish_utils.get_system.return_value + + mock_manager = mock.MagicMock() + + mock_system.managers = [mock_manager] + + mock_manager_oem = mock_manager.get_oem_extension.return_value + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.boot._set_boot_device( + task, boot_devices.CDROM, persistent=True) + + mock_manager_oem.set_virtual_boot_device.assert_called_once_with( + 'cd', persistent=True, manager=mock_manager, + system=mock_system) + + def test__set_boot_device_cd(self, mock_redfish_utils): + + mock_system = mock_redfish_utils.get_system.return_value + + mock_manager = mock.MagicMock() + + mock_system.managers = [mock_manager] + + mock_manager_oem = mock_manager.get_oem_extension.return_value + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.boot._set_boot_device(task, boot_devices.CDROM) + + mock_manager_oem.set_virtual_boot_device.assert_called_once_with( + 'cd', persistent=False, manager=mock_manager, + system=mock_system) + + def test__set_boot_device_floppy(self, mock_redfish_utils): + + mock_system = mock_redfish_utils.get_system.return_value + + mock_manager = mock.MagicMock() + + mock_system.managers = [mock_manager] + + mock_manager_oem = mock_manager.get_oem_extension.return_value + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.boot._set_boot_device(task, boot_devices.FLOPPY) + + mock_manager_oem.set_virtual_boot_device.assert_called_once_with( + 'floppy', persistent=False, manager=mock_manager, + system=mock_system) + + def test__set_boot_device_disk(self, mock_redfish_utils): + + mock_system = mock_redfish_utils.get_system.return_value + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.boot._set_boot_device(task, boot_devices.DISK) + + self.assertFalse(mock_system.called) + + def test__set_boot_device_missing_oem(self, mock_redfish_utils): + + mock_system = mock_redfish_utils.get_system.return_value + + mock_manager = mock.MagicMock() + + mock_system.managers = [mock_manager] + + mock_manager.get_oem_extension.side_effect = ( + sushy.exceptions.OEMExtensionNotFoundError) + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.assertRaises(exception.RedfishError, + task.driver.boot._set_boot_device, + task, boot_devices.CDROM) + + def test__set_boot_device_failover(self, mock_redfish_utils): + + mock_system = mock_redfish_utils.get_system.return_value + + mock_manager_fail = mock.MagicMock() + mock_manager_ok = mock.MagicMock() + + mock_system.managers = [mock_manager_fail, mock_manager_ok] + + mock_svbd_fail = (mock_manager_fail.get_oem_extension + .return_value.set_virtual_boot_device) + + mock_svbd_ok = (mock_manager_ok.get_oem_extension + .return_value.set_virtual_boot_device) + + mock_svbd_fail.side_effect = sushy.exceptions.SushyError + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.boot._set_boot_device(task, boot_devices.CDROM) + + self.assertFalse(mock_system.called) + + mock_svbd_fail.assert_called_once_with( + 'cd', manager=mock_manager_fail, persistent=False, + system=mock_system) + + mock_svbd_ok.assert_called_once_with( + 'cd', manager=mock_manager_ok, persistent=False, + system=mock_system) + + def test__set_boot_device_no_manager(self, mock_redfish_utils): + + mock_system = mock_redfish_utils.get_system.return_value + + mock_system.managers = [] + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.assertRaises(exception.RedfishError, + task.driver.boot._set_boot_device, + task, boot_devices.CDROM) diff --git a/ironic/tests/unit/drivers/modules/drac/utils.py b/ironic/tests/unit/drivers/modules/drac/utils.py index f9bc6fc634..1828f3faf5 100644 --- a/ironic/tests/unit/drivers/modules/drac/utils.py +++ b/ironic/tests/unit/drivers/modules/drac/utils.py @@ -29,6 +29,8 @@ class BaseDracTest(db_base.DbTestCase): def setUp(self): super(BaseDracTest, self).setUp() self.config(enabled_hardware_types=['idrac', 'fake-hardware'], + enabled_boot_interfaces=[ + 'idrac-redfish-virtual-media', 'fake'], enabled_power_interfaces=['idrac-wsman', 'fake'], enabled_management_interfaces=['idrac-wsman', 'fake'], enabled_inspect_interfaces=[ diff --git a/ironic/tests/unit/drivers/test_drac.py b/ironic/tests/unit/drivers/test_drac.py index 3dfe50424c..c5b56d0193 100644 --- a/ironic/tests/unit/drivers/test_drac.py +++ b/ironic/tests/unit/drivers/test_drac.py @@ -16,10 +16,10 @@ from ironic.conductor import task_manager from ironic.drivers.modules import agent from ironic.drivers.modules import drac from ironic.drivers.modules import inspector +from ironic.drivers.modules import ipxe from ironic.drivers.modules import iscsi_deploy from ironic.drivers.modules.network import flat as flat_net from ironic.drivers.modules import noop -from ironic.drivers.modules import pxe from ironic.drivers.modules.storage import noop as noop_storage from ironic.tests.unit.db import base as db_base from ironic.tests.unit.objects import utils as obj_utils @@ -29,7 +29,10 @@ class IDRACHardwareTestCase(db_base.DbTestCase): def setUp(self): super(IDRACHardwareTestCase, self).setUp() + self.config_temp_dir('http_root', group='deploy') self.config(enabled_hardware_types=['idrac'], + enabled_boot_interfaces=[ + 'idrac-redfish-virtual-media', 'ipxe', 'pxe'], enabled_management_interfaces=[ 'idrac', 'idrac-redfish', 'idrac-wsman'], enabled_power_interfaces=[ @@ -46,7 +49,7 @@ class IDRACHardwareTestCase(db_base.DbTestCase): def _validate_interfaces(self, driver, **kwargs): self.assertIsInstance( driver.boot, - kwargs.get('boot', pxe.PXEBoot)) + kwargs.get('boot', ipxe.iPXEBoot)) self.assertIsInstance( driver.deploy, kwargs.get('deploy', iscsi_deploy.ISCSIDeploy)) @@ -149,3 +152,12 @@ class IDRACHardwareTestCase(db_base.DbTestCase): self._validate_interfaces( task.driver, inspect=drac.inspect.DracRedfishInspect) + + def test_override_with_redfish_virtual_media_boot(self): + node = obj_utils.create_test_node( + self.context, driver='idrac', + boot_interface='idrac-redfish-virtual-media') + with task_manager.acquire(self.context, node.id) as task: + self._validate_interfaces( + task.driver, + boot=drac.boot.DracRedfishVirtualMediaBoot) diff --git a/ironic/tests/unit/drivers/third_party_driver_mocks.py b/ironic/tests/unit/drivers/third_party_driver_mocks.py index 53a2b17ae4..ef13238504 100644 --- a/ironic/tests/unit/drivers/third_party_driver_mocks.py +++ b/ironic/tests/unit/drivers/third_party_driver_mocks.py @@ -213,6 +213,8 @@ if not sushy: type('ResourceNotFoundError', (sushy.exceptions.SushyError,), {})) sushy.exceptions.MissingAttributeError = ( type('MissingAttributeError', (sushy.exceptions.SushyError,), {})) + sushy.exceptions.OEMExtensionNotFoundError = ( + type('OEMExtensionNotFoundError', (sushy.exceptions.SushyError,), {})) sushy.auth = mock.MagicMock(spec_set=mock_specs.SUSHY_AUTH_SPEC) sys.modules['sushy.auth'] = sushy.auth diff --git a/releasenotes/notes/idrac-add-redfish-boot-support-036396b48d3f71f4.yaml b/releasenotes/notes/idrac-add-redfish-boot-support-036396b48d3f71f4.yaml new file mode 100644 index 0000000000..00c581daff --- /dev/null +++ b/releasenotes/notes/idrac-add-redfish-boot-support-036396b48d3f71f4.yaml @@ -0,0 +1,21 @@ +--- +features: + - | + Adds ``idrac`` hardware type support of a virtual media boot + interface implementation that utilizes the Redfish out-of-band (OOB) + management protocol and is compatible with the integrated Dell + Remote Access Controller (iDRAC) baseboard management controller + (BMC). It is named ``idrac-redfish-virtual-media``. + + The ``idrac`` hardware type declares support for that new interface + implementation, in addition to all boot interface implementations it + has been supporting. The highest priority boot interfaces remain the + same. It now supports the following boot interface implementations, + listed in priority order from highest to lowest: ``ipxe``, ``pxe``, + and ``idrac-redfish-virtual-media``. + + To use the new boot interface, install the ``sushy-oem-idrac`` + Python package. + + For more information, see `story 2006570 + `_. diff --git a/setup.cfg b/setup.cfg index 4a4c9c84ce..c993ab8faf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,6 +63,7 @@ ironic.hardware.interfaces.bios = ironic.hardware.interfaces.boot = fake = ironic.drivers.modules.fake:FakeBoot + idrac-redfish-virtual-media = ironic.drivers.modules.drac.boot:DracRedfishVirtualMediaBoot ilo-pxe = ironic.drivers.modules.ilo.boot:IloPXEBoot ilo-ipxe = ironic.drivers.modules.ilo.boot:IloiPXEBoot ilo-virtual-media = ironic.drivers.modules.ilo.boot:IloVirtualMediaBoot