diff --git a/doc/source/admin/drivers.rst b/doc/source/admin/drivers.rst index 6eca9d392a..91c92026e3 100644 --- a/doc/source/admin/drivers.rst +++ b/doc/source/admin/drivers.rst @@ -18,6 +18,7 @@ Hardware Types :maxdepth: 1 drivers/cimc + drivers/ibmc drivers/idrac drivers/ilo drivers/ipmitool diff --git a/doc/source/admin/drivers/ibmc.rst b/doc/source/admin/drivers/ibmc.rst new file mode 100644 index 0000000000..d7adb0dabb --- /dev/null +++ b/doc/source/admin/drivers/ibmc.rst @@ -0,0 +1,119 @@ +=============== +iBMC driver +=============== + +Overview +======== + +The ``ibmc`` driver is targeted for Huawei rack server 2288H V5, CH121 V5. +The iBMC hardware type enables the user to take advantage of features of +`Huawei iBMC`_ to control Huawei server. + +Prerequisites +============= + +The `HUAWEI iBMC Client library`_ should be installed on the ironic conductor + node(s). + +For example, it can be installed with ``pip``:: + + sudo pip install python-ibmcclient + +Enabling the iBMC driver +============================ + +#. Add ``ibmc`` to the list of ``enabled_hardware_types``, + ``enabled_power_interfaces``, ``enabled_vendor_interfaces`` + and ``enabled_management_interfaces`` in ``/etc/ironic/ironic.conf``. For example:: + + [DEFAULT] + ... + enabled_hardware_types = ibmc,ipmi + enabled_power_interfaces = ibmc,ipmitool + enabled_management_interfaces = ibmc,ipmitool + enabled_vendor_interfaces = ibmc + +#. Restart the ironic conductor service:: + + sudo service ironic-conductor restart + + # Or, for RDO: + sudo systemctl restart openstack-ironic-conductor + +Registering a node with the iBMC driver +=========================================== + +Nodes configured to use the driver should have the ``driver`` property +set to ``ibmc``. + +The following properties are specified in the node's ``driver_info`` +field: + +- ``ibmc_address``: + + The URL address to the ibmc controller. It must + include the authority portion of the URL, and can + optionally include the scheme. If the scheme is + missing, https is assumed. + For example: https://ibmc.example.com. This is required. + +- ``ibmc_username``: + + User account with admin/server-profile access + privilege. This is required. + +- ``ibmc_password``: + + User account password. This is required. + +- ``ibmc_verify_ca``: + + If ibmc_address has the **https** scheme, the + driver will use a secure (TLS_) connection when + talking to the ibmc controller. By default + (if this is set to True), the driver will try to + verify the host certificates. This can be set to + the path of a certificate file or directory with + trusted certificates that the driver will use for + verification. To disable verifying TLS_, set this + to False. This is optional. + +The ``openstack baremetal node create`` command can be used to enroll +a node with the ``ibmc`` driver. For example: + +.. code-block:: bash + + openstack baremetal node create --driver ibmc + --driver-info ibmc_address=https://example.com \ + --driver-info ibmc_username=admin \ + --driver-info ibmc_password=password + +For more information about enrolling nodes see :ref:`enrollment` +in the install guide. + +Features of the ``ibmc`` hardware type +========================================= + +Query boot up sequence +^^^^^^^^^^^^^^^^^^^^^^ + +The ``ibmc`` hardware type can query current boot up sequence from the +bare metal node + +.. code-block:: bash + + openstack baremetal node passthru call --http-method GET \ + boot_up_seq + + +PXE Boot and iSCSI Deploy Process with Ironic Standalone Environment +==================================================================== + +.. figure:: ../../images/ironic_standalone_with_ibmc_driver.svg + :width: 960px + :align: left + :alt: Ironic standalone with iBMC driver node + +.. _Huawei iBMC: https://e.huawei.com/en/products/cloud-computing-dc/servers/accessories/ibmc +.. _TLS: https://en.wikipedia.org/wiki/Transport_Layer_Security +.. _HUAWEI iBMC Client library: https://pypi.org/project/python-ibmcclient/ diff --git a/doc/source/images/ironic_standalone_with_ibmc_driver.svg b/doc/source/images/ironic_standalone_with_ibmc_driver.svg new file mode 100644 index 0000000000..d2ef253e2b --- /dev/null +++ b/doc/source/images/ironic_standalone_with_ibmc_driver.svg @@ -0,0 +1,1309 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + API + + + + + API + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Conductor + + + + + Conductor + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + do node deploy + + + + + + + + + + User + + + + + User + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Create ibmc driver node + + + + + + + + Set driver_info (ibmc_address, ibmc_username, ibmc_password, etc) + + + + + + + + Set instance_info(image_source, root_gb, etc.) + + + + + + + + Validate power, management and vendor interfaces + + + + + + + + Create bare metal node network port + + + + + + + + Set provision_state, optionally pass configdrive + + + + + + + + + + DHCP + + + + + DHCP + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TFTP + + + + + TFTP + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Validate power, management and vendor interfaces + + + + + + + + + + Node + + + + + Node + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Set PXE boot devicethrough iBMC + + + + + + + + REBOOT through iBMC + + + + + + + + + + + + + Prepare PXEenvironment fordeployment + + + + + + + Run agent ramdisk + + + + + + + + + + + + + + Send PXE DHCP request + + + + + + + + Offer IP to node + + + + + + + + Send PXE image and agent image + + + + + + + + + + + + + + + + + + + + + + + + + + + Send IPA a command to expose disks via iSCSI + + + + + + + + iSCSI attach + + + + + + + + Copies user image and configdrive, if presend + + + + + + + + iSCSI detach + + + + + + + + Install boot loader if requested + + + + + + + + Set boot device either to PXE or to disk + + + + + + + + Collect ramdisk logs + + + + + + + + POWER OFF + + + + + + + + POWER ON + + + + + + + Mark node as ACTIVE + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + 2 + + + + + + + + + + 1 + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + + + + + 1 + + + + + + + IBMC management interface + + + + + + + IBMC power interface + + + + + + + \ No newline at end of file diff --git a/driver-requirements.txt b/driver-requirements.txt index 8241d62503..6a756f7a85 100644 --- a/driver-requirements.txt +++ b/driver-requirements.txt @@ -20,3 +20,6 @@ sushy>=1.6.0 # Ansible-deploy interface ansible>=2.4 + +# HUAWEI iBMC hardware type uses the python-ibmcclient library +python-ibmcclient>=0.1.0 diff --git a/ironic/common/exception.py b/ironic/common/exception.py index c6211ed9a8..88c64a84d7 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -823,3 +823,11 @@ class DeployTemplateNotFound(NotFound): class InvalidDeployTemplate(Invalid): _msg_fmt = _("Deploy template invalid: %(err)s.") + + +class IBMCError(DriverOperationError): + _msg_fmt = _("IBMC exception occurred on node %(node)s. Error: %(error)s") + + +class IBMCConnectionError(IBMCError): + _msg_fmt = _("IBMC connection failed for node %(node)s: %(error)s") diff --git a/ironic/conf/__init__.py b/ironic/conf/__init__.py index a2d4ae4b63..01d7042b34 100644 --- a/ironic/conf/__init__.py +++ b/ironic/conf/__init__.py @@ -30,6 +30,7 @@ from ironic.conf import dhcp from ironic.conf import drac from ironic.conf import glance from ironic.conf import healthcheck +from ironic.conf import ibmc from ironic.conf import ilo from ironic.conf import inspector from ironic.conf import ipmi @@ -63,6 +64,7 @@ drac.register_opts(CONF) dhcp.register_opts(CONF) glance.register_opts(CONF) healthcheck.register_opts(CONF) +ibmc.register_opts(CONF) ilo.register_opts(CONF) inspector.register_opts(CONF) ipmi.register_opts(CONF) diff --git a/ironic/conf/ibmc.py b/ironic/conf/ibmc.py new file mode 100644 index 0000000000..82c5eaac75 --- /dev/null +++ b/ironic/conf/ibmc.py @@ -0,0 +1,35 @@ +# +# 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. + +# Version 1.0.0 + +from oslo_config import cfg + +from ironic.common.i18n import _ + +opts = [ + cfg.IntOpt('connection_attempts', + min=1, + default=5, + help=_('Maximum number of attempts to try to connect ' + 'to iBMC')), + cfg.IntOpt('connection_retry_interval', + min=1, + default=4, + help=_('Number of seconds to wait between attempts to ' + 'connect to iBMC')) +] + + +def register_opts(conf): + conf.register_opts(opts, group='ibmc') diff --git a/ironic/drivers/ibmc.py b/ironic/drivers/ibmc.py new file mode 100644 index 0000000000..a9d659ecfd --- /dev/null +++ b/ironic/drivers/ibmc.py @@ -0,0 +1,40 @@ +# +# 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. +""" +iBMC Driver for managing HUAWEI Huawei 2288H V5, CH121 V5 series servers. +""" + +from ironic.drivers import generic +from ironic.drivers.modules.ibmc import management as ibmc_mgmt +from ironic.drivers.modules.ibmc import power as ibmc_power +from ironic.drivers.modules.ibmc import vendor as ibmc_vendor +from ironic.drivers.modules import noop + + +class IBMCHardware(generic.GenericHardware): + """Huawei iBMC hardware type.""" + + @property + def supported_management_interfaces(self): + """List of supported management interfaces.""" + return [ibmc_mgmt.IBMCManagement] + + @property + def supported_power_interfaces(self): + """List of supported power interfaces.""" + return [ibmc_power.IBMCPower] + + @property + def supported_vendor_interfaces(self): + """List of supported vendor interfaces.""" + return [ibmc_vendor.IBMCVendor, noop.NoVendor] diff --git a/ironic/drivers/modules/ibmc/__init__.py b/ironic/drivers/modules/ibmc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/drivers/modules/ibmc/management.py b/ironic/drivers/modules/ibmc/management.py new file mode 100644 index 0000000000..672501a7b5 --- /dev/null +++ b/ironic/drivers/modules/ibmc/management.py @@ -0,0 +1,237 @@ +# Copyright 2019 HUAWEI, Inc. All Rights Reserved. +# Copyright 2017 Red Hat, 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. +""" +iBMC Management Interface +""" + +from oslo_log import log +from oslo_utils import importutils + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.conductor import task_manager +from ironic.drivers import base +from ironic.drivers.modules.ibmc import mappings +from ironic.drivers.modules.ibmc import utils + +constants = importutils.try_import('ibmc_client.constants') +ibmc_client = importutils.try_import('ibmc_client') + +LOG = log.getLogger(__name__) + + +class IBMCManagement(base.ManagementInterface): + + def __init__(self): + """Initialize the iBMC management interface + + :raises: DriverLoadError if the driver can't be loaded due to + missing dependencies + """ + super(IBMCManagement, self).__init__() + if not ibmc_client: + raise exception.DriverLoadError( + driver='ibmc', + reason=_('Unable to import the python-ibmcclient library')) + + def get_properties(self): + """Return the properties of the interface. + + :returns: dictionary of : entries. + """ + return utils.COMMON_PROPERTIES.copy() + + def validate(self, task): + """Validates the driver information needed by the iBMC driver. + + :param task: A TaskManager instance containing the node to act on. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + """ + utils.parse_driver_info(task.node) + + @utils.handle_ibmc_exception('get iBMC supported boot devices') + def get_supported_boot_devices(self, task): + """Get a list of the supported boot devices. + + :param task: a task from TaskManager. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + :raises: IBMCConnectionError when it fails to connect to iBMC + :raises: IBMCError when iBMC responses an error information + :returns: A list with the supported boot devices defined + in :mod:`ironic.common.boot_devices`. + """ + ibmc = utils.parse_driver_info(task.node) + with ibmc_client.connect(**ibmc) as conn: + system = conn.system.get() + boot_source_override = system.boot_source_override + return list(map(mappings.GET_BOOT_DEVICE_MAP.get, + boot_source_override.supported_boot_devices)) + + @task_manager.require_exclusive_lock + @utils.handle_ibmc_exception('set iBMC boot device') + def set_boot_device(self, task, device, persistent=False): + """Set the boot device for a node. + + :param task: A task from TaskManager. + :param device: The boot device, one of + :mod:`ironic.common.boot_device`. + :param persistent: Boolean value. True if the boot device will + persist to all future boots, False if not. + Default: False. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + :raises: IBMCConnectionError when it fails to connect to iBMC + :raises: IBMCError when iBMC responses an error information + """ + ibmc = utils.parse_driver_info(task.node) + with ibmc_client.connect(**ibmc) as conn: + boot_device = mappings.SET_BOOT_DEVICE_MAP[device] + enabled = mappings.SET_BOOT_DEVICE_PERSISTENT_MAP[persistent] + conn.system.set_boot_source(boot_device, enabled=enabled) + + @utils.handle_ibmc_exception('get iBMC boot device') + def get_boot_device(self, task): + """Get the current boot device for a node. + + :param task: A task from TaskManager. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + :raises: IBMCConnectionError when it fails to connect to iBMC + :raises: IBMCError when iBMC responses an error information + :returns: a dictionary containing: + + :boot_device: + the boot device, one of :mod:`ironic.common.boot_devices` or + None if it is unknown. + :persistent: + Boolean value or None, True if the boot device persists, + False otherwise. None if it's disabled. + + """ + ibmc = utils.parse_driver_info(task.node) + with ibmc_client.connect(**ibmc) as conn: + system = conn.system.get() + boot_source_override = system.boot_source_override + boot_device = boot_source_override.target + enabled = boot_source_override.enabled + return { + 'boot_device': mappings.GET_BOOT_DEVICE_MAP.get(boot_device), + 'persistent': + mappings.GET_BOOT_DEVICE_PERSISTENT_MAP.get(enabled) + } + + def get_supported_boot_modes(self, task): + """Get a list of the supported boot modes. + + :param task: A task from TaskManager. + :returns: A list with the supported boot modes defined + in :mod:`ironic.common.boot_modes`. If boot + mode support can't be determined, empty list + is returned. + """ + return list(mappings.SET_BOOT_MODE_MAP) + + @task_manager.require_exclusive_lock + @utils.handle_ibmc_exception('set iBMC boot mode') + def set_boot_mode(self, task, mode): + """Set the boot mode for a node. + + Set the boot mode to use on next reboot of the node. + + :param task: A task from TaskManager. + :param mode: The boot mode, one of + :mod:`ironic.common.boot_modes`. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + :raises: IBMCConnectionError when it fails to connect to iBMC + :raises: IBMCError when iBMC responses an error information + """ + ibmc = utils.parse_driver_info(task.node) + with ibmc_client.connect(**ibmc) as conn: + system = conn.system.get() + boot_source_override = system.boot_source_override + boot_device = boot_source_override.target + boot_override = boot_source_override.enabled + + # Copied from redfish driver + # TODO(Qianbiao.NG) what if boot device is "NONE"? + if not boot_device: + error_msg = (_('Cannot change boot mode on node %(node)s ' + 'because its boot device is not set.') % + {'node': task.node.uuid}) + LOG.error(error_msg) + raise exception.IBMCError(error_msg) + + # TODO(Qianbiao.NG) what if boot override is "disabled"? + if not boot_override: + i18n = _('Cannot change boot mode on node %(node)s ' + 'because its boot source override is not set.') + error_msg = i18n % {'node': task.node.uuid} + LOG.error(error_msg) + raise exception.IBMCError(error_msg) + + boot_mode = mappings.SET_BOOT_MODE_MAP[mode] + conn.system.set_boot_source(boot_device, + enabled=boot_override, + mode=boot_mode) + + @utils.handle_ibmc_exception('get iBMC boot mode') + def get_boot_mode(self, task): + """Get the current boot mode for a node. + + Provides the current boot mode of the node. + + :param task: A task from TaskManager. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + :raises: IBMCConnectionError when it fails to connect to iBMC + :raises: IBMCError when iBMC responses an error information + :returns: The boot mode, one of :mod:`ironic.common.boot_mode` or + None if it is unknown. + """ + ibmc = utils.parse_driver_info(task.node) + with ibmc_client.connect(**ibmc) as conn: + system = conn.system.get() + boot_source_override = system.boot_source_override + boot_mode = boot_source_override.mode + return mappings.GET_BOOT_MODE_MAP.get(boot_mode) + + def get_sensors_data(self, task): + """Get sensors data. + + Not implemented for this driver. + + :raises: NotImplementedError + """ + raise NotImplementedError() + + @task_manager.require_exclusive_lock + @utils.handle_ibmc_exception('inject iBMC NMI') + def inject_nmi(self, task): + """Inject NMI, Non Maskable Interrupt. + + Inject NMI (Non Maskable Interrupt) for a node immediately. + + :param task: A TaskManager instance containing the node to act on. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + :raises: IBMCConnectionError when it fails to connect to iBMC + :raises: IBMCError when iBMC responses an error information + """ + ibmc = utils.parse_driver_info(task.node) + with ibmc_client.connect(**ibmc) as conn: + conn.system.reset(constants.RESET_NMI) diff --git a/ironic/drivers/modules/ibmc/mappings.py b/ironic/drivers/modules/ibmc/mappings.py new file mode 100644 index 0000000000..e303a72ea0 --- /dev/null +++ b/ironic/drivers/modules/ibmc/mappings.py @@ -0,0 +1,70 @@ +# +# 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. +""" +iBMC and Ironic constants mapping +""" + +from oslo_utils import importutils + +from ironic.common import boot_devices +from ironic.common import boot_modes +from ironic.common import states +from ironic.drivers.modules.ibmc import utils + +constants = importutils.try_import('ibmc_client.constants') + +if constants: + # Set power state mapping + SET_POWER_STATE_MAP = { + states.POWER_ON: constants.RESET_ON, + states.POWER_OFF: constants.RESET_FORCE_OFF, + states.REBOOT: constants.RESET_FORCE_RESTART, + states.SOFT_REBOOT: constants.RESET_FORCE_POWER_CYCLE, + states.SOFT_POWER_OFF: constants.RESET_GRACEFUL_SHUTDOWN, + } + + # Get power state mapping + GET_POWER_STATE_MAP = { + constants.SYSTEM_POWER_STATE_ON: states.POWER_ON, + constants.SYSTEM_POWER_STATE_OFF: states.POWER_OFF, + } + + # Boot device mapping + GET_BOOT_DEVICE_MAP = { + constants.BOOT_SOURCE_TARGET_NONE: 'none', + constants.BOOT_SOURCE_TARGET_PXE: boot_devices.PXE, + constants.BOOT_SOURCE_TARGET_FLOPPY: 'floppy', + constants.BOOT_SOURCE_TARGET_CD: boot_devices.CDROM, + constants.BOOT_SOURCE_TARGET_HDD: boot_devices.DISK, + constants.BOOT_SOURCE_TARGET_BIOS_SETUP: boot_devices.BIOS, + } + + SET_BOOT_DEVICE_MAP = utils.revert_dictionary(GET_BOOT_DEVICE_MAP) + + # Boot mode mapping + GET_BOOT_MODE_MAP = { + constants.BOOT_SOURCE_MODE_BIOS: boot_modes.LEGACY_BIOS, + constants.BOOT_SOURCE_MODE_UEFI: boot_modes.UEFI, + } + + SET_BOOT_MODE_MAP = utils.revert_dictionary(GET_BOOT_MODE_MAP) + + # Boot device persistent mapping + GET_BOOT_DEVICE_PERSISTENT_MAP = { + constants.BOOT_SOURCE_ENABLED_ONCE: False, + constants.BOOT_SOURCE_ENABLED_CONTINUOUS: True, + constants.BOOT_SOURCE_ENABLED_DISABLED: None, + } + + SET_BOOT_DEVICE_PERSISTENT_MAP = utils.revert_dictionary( + GET_BOOT_DEVICE_PERSISTENT_MAP) diff --git a/ironic/drivers/modules/ibmc/power.py b/ironic/drivers/modules/ibmc/power.py new file mode 100644 index 0000000000..6bb15ee222 --- /dev/null +++ b/ironic/drivers/modules/ibmc/power.py @@ -0,0 +1,145 @@ +# +# 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. +""" +iBMC Power Interface +""" + +from oslo_log import log +from oslo_utils import importutils + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common import states +from ironic.conductor import task_manager +from ironic.conductor import utils as cond_utils +from ironic.drivers import base +from ironic.drivers.modules.ibmc import mappings +from ironic.drivers.modules.ibmc import utils + +constants = importutils.try_import('ibmc_client.constants') +ibmc_client = importutils.try_import('ibmc_client') + +LOG = log.getLogger(__name__) + +EXPECT_POWER_STATE_MAP = { + states.REBOOT: states.POWER_ON, + states.SOFT_REBOOT: states.POWER_ON, + states.SOFT_POWER_OFF: states.POWER_OFF, +} + + +class IBMCPower(base.PowerInterface): + + def __init__(self): + """Initialize the iBMC power interface. + + :raises: DriverLoadError if the driver can't be loaded due to + missing dependencies + """ + super(IBMCPower, self).__init__() + if not ibmc_client: + raise exception.DriverLoadError( + driver='ibmc', + reason=_('Unable to import the python-ibmcclient library')) + + def get_properties(self): + """Return the properties of the interface. + + :returns: dictionary of : entries. + """ + return utils.COMMON_PROPERTIES.copy() + + def validate(self, task): + """Validates the driver information needed by the iBMC driver. + + :param task: A TaskManager instance containing the node to act on. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + """ + utils.parse_driver_info(task.node) + + @utils.handle_ibmc_exception('get iBMC power state') + def get_power_state(self, task): + """Get the current power state of the task's node. + + :param task: A TaskManager instance containing the node to act on. + :returns: A power state. One of :mod:`ironic.common.states`. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + :raises: IBMCConnectionError when it fails to connect to iBMC + :raises: IBMCError when iBMC responses an error information + """ + ibmc = utils.parse_driver_info(task.node) + with ibmc_client.connect(**ibmc) as conn: + system = conn.system.get() + return mappings.GET_POWER_STATE_MAP.get(system.power_state) + + @task_manager.require_exclusive_lock + @utils.handle_ibmc_exception('set iBMC power state') + def set_power_state(self, task, power_state, timeout=None): + """Set the power state of the task's node. + + :param task: A TaskManager instance containing the node to act on. + :param power_state: Any power state from :mod:`ironic.common.states`. + :param timeout: Time to wait for the node to reach the requested state. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue if a required parameter is missing. + :raises: IBMCConnectionError when it fails to connect to iBMC + :raises: IBMCError when iBMC responses an error information + """ + ibmc = utils.parse_driver_info(task.node) + with ibmc_client.connect(**ibmc) as conn: + reset_type = mappings.SET_POWER_STATE_MAP.get(power_state) + conn.system.reset(reset_type) + + target_state = EXPECT_POWER_STATE_MAP.get(power_state, power_state) + cond_utils.node_wait_for_power_state(task, target_state, + timeout=timeout) + + @task_manager.require_exclusive_lock + @utils.handle_ibmc_exception('reboot iBMC') + def reboot(self, task, timeout=None): + """Perform a hard reboot of the task's node. + + :param task: A TaskManager instance containing the node to act on. + :param timeout: Time to wait for the node to become powered on. + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue if a required parameter is missing. + :raises: IBMCConnectionError when it fails to connect to iBMC + :raises: IBMCError when iBMC responses an error information + """ + ibmc = utils.parse_driver_info(task.node) + with ibmc_client.connect(**ibmc) as conn: + system = conn.system.get() + current_power_state = ( + mappings.GET_POWER_STATE_MAP.get(system.power_state) + ) + if current_power_state == states.POWER_ON: + conn.system.reset( + mappings.SET_POWER_STATE_MAP.get(states.REBOOT)) + else: + conn.system.reset( + mappings.SET_POWER_STATE_MAP.get(states.POWER_ON)) + + cond_utils.node_wait_for_power_state(task, states.POWER_ON, + timeout=timeout) + + def get_supported_power_states(self, task): + """Get a list of the supported power states. + + :param task: A TaskManager instance containing the node to act on. + Not used by this driver at the moment. + :returns: A list with the supported power states defined + in :mod:`ironic.common.states`. + """ + return list(mappings.SET_POWER_STATE_MAP) diff --git a/ironic/drivers/modules/ibmc/utils.py b/ironic/drivers/modules/ibmc/utils.py new file mode 100644 index 0000000000..d06a88b85a --- /dev/null +++ b/ironic/drivers/modules/ibmc/utils.py @@ -0,0 +1,177 @@ +# +# 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. +""" +iBMC Driver common utils +""" + +import os + +from oslo_log import log +from oslo_utils import importutils +from oslo_utils import netutils +from oslo_utils import strutils +import retrying +import six + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.conductor import task_manager +from ironic.conf import CONF + +ibmc_client = importutils.try_import('ibmcclient') +ibmc_error = importutils.try_import('ibmc_client.exceptions') + +LOG = log.getLogger(__name__) + +REQUIRED_PROPERTIES = { + 'ibmc_address': _('The URL address to the iBMC controller. It must ' + 'include the authority portion of the URL. ' + 'If the scheme is missing, https is assumed. ' + 'For example: https://mgmt.vendor.com. Required.'), + 'ibmc_username': _('User account with admin/server-profile access ' + 'privilege. Required.'), + 'ibmc_password': _('User account password. Required.'), +} + +OPTIONAL_PROPERTIES = { + 'ibmc_verify_ca': _('Either a Boolean value, a path to a CA_BUNDLE ' + 'file or directory with certificates of trusted ' + 'CAs. If set to True the driver will verify the ' + 'host certificates; if False the driver will ' + 'ignore verifying the SSL certificate. If it\'s ' + 'a path the driver will use the specified ' + 'certificate or one of the certificates in the ' + 'directory. Defaults to True. Optional.'), +} + +COMMON_PROPERTIES = REQUIRED_PROPERTIES.copy() +COMMON_PROPERTIES.update(OPTIONAL_PROPERTIES) + + +def parse_driver_info(node): + """Parse the information required for Ironic to connect to iBMC. + + :param node: an Ironic node object + :returns: dictionary of parameters + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + """ + driver_info = node.driver_info or {} + missing_info = [key for key in REQUIRED_PROPERTIES + if not driver_info.get(key)] + if missing_info: + raise exception.MissingParameterValue(_( + 'Missing the following iBMC properties in node ' + '%(node)s driver_info: %(info)s') % {'node': node.uuid, + 'info': missing_info}) + + # Validate the iBMC address + address = driver_info['ibmc_address'] + parsed = netutils.urlsplit(address) + if parsed.scheme == '': + address = 'https://%s' % address + parsed = netutils.urlsplit(address) + + if parsed.netloc == '': + raise exception.InvalidParameterValue( + _('Invalid iBMC address %(address)s set in ' + 'driver_info/ibmc_address on node %(node)s') % + {'address': address, 'node': node.uuid}) + + # Check if verify_ca is a Boolean or a file/directory in the file-system + verify_ca = driver_info.get('ibmc_verify_ca', True) + if isinstance(verify_ca, six.string_types): + if os.path.isdir(verify_ca) or os.path.isfile(verify_ca): + pass + else: + try: + verify_ca = strutils.bool_from_string(verify_ca, strict=True) + except ValueError: + raise exception.InvalidParameterValue( + _('Invalid value type set in driver_info/' + 'ibmc_verify_ca on node %(node)s. ' + 'The value should be a Boolean or the path ' + 'to a file/directory, not "%(value)s"' + ) % {'value': verify_ca, 'node': node.uuid}) + elif isinstance(verify_ca, bool): + # If it's a boolean it's grand, we don't need to do anything + pass + else: + raise exception.InvalidParameterValue( + _('Invalid value type set in driver_info/ibmc_verify_ca ' + 'on node %(node)s. The value should be a Boolean or the path ' + 'to a file/directory, not "%(value)s"') % {'value': verify_ca, + 'node': node.uuid}) + return {'address': address, + 'username': driver_info.get('ibmc_username'), + 'password': driver_info.get('ibmc_password'), + 'verify_ca': verify_ca} + + +def revert_dictionary(d): + return {v: k for k, v in d.items()} + + +def handle_ibmc_exception(action): + """Decorator to handle iBMC client exception. + + Decorated functions must take a :class:`TaskManager` as the first + parameter. + """ + + def decorator(f): + + def should_retry(e): + connect_error = isinstance(e, exception.IBMCConnectionError) + if connect_error: + LOG.info(_('Failed to connect to iBMC, will retry now. ' + 'Max retry times is %(retry_times)d.'), + {'retry_times': CONF.ibmc.connection_attempts}) + return connect_error + + @retrying.retry( + retry_on_exception=should_retry, + stop_max_attempt_number=CONF.ibmc.connection_attempts, + wait_fixed=CONF.ibmc.connection_retry_interval * 1000) + @six.wraps(f) + def wrapper(*args, **kwargs): + # NOTE(dtantsur): this code could be written simpler, but then unit + # testing decorated functions is pretty hard, as we usually pass a + # Mock object instead of TaskManager there. + if len(args) > 1: + is_task_mgr = isinstance(args[1], task_manager.TaskManager) + task = args[1] if is_task_mgr else args[0] + else: + task = args[0] + + node = task.node + + try: + return f(*args, **kwargs) + except ibmc_error.ConnectionError as e: + error = (_('Failed to connect to iBMC for node %(node)s, ' + 'Error: %(error)s') + % {'node': node.uuid, 'error': e}) + LOG.error(error) + raise exception.IBMCConnectionError(node=node.uuid, + error=error) + except ibmc_error.IBMCClientError as e: + error = (_('Failed to %(action)s for node %(node)s, ' + 'Error %(error)s') + % {'node': node.uuid, 'action': action, 'error': e}) + LOG.error(error) + raise exception.IBMCError(node=node.uuid, error=error) + + return wrapper + + return decorator diff --git a/ironic/drivers/modules/ibmc/vendor.py b/ironic/drivers/modules/ibmc/vendor.py new file mode 100644 index 0000000000..24d497cf72 --- /dev/null +++ b/ironic/drivers/modules/ibmc/vendor.py @@ -0,0 +1,87 @@ +# +# 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. +""" +iBMC Vendor Interface +""" + +from oslo_log import log +from oslo_utils import importutils + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.drivers import base +from ironic.drivers.modules.ibmc import utils + +ibmc_client = importutils.try_import('ibmc_client') + +LOG = log.getLogger(__name__) + + +class IBMCVendor(base.VendorInterface): + + def __init__(self): + """Initialize the iBMC vendor interface. + + :raises: DriverLoadError if the driver can't be loaded due to + missing dependencies + """ + super(IBMCVendor, self).__init__() + if not ibmc_client: + raise exception.DriverLoadError( + driver='ibmc', + reason=_('Unable to import the python-ibmcclient library')) + + def validate(self, task, method=None, **kwargs): + """Validate vendor-specific actions. + + If invalid, raises an exception; otherwise returns None. + + :param task: A task from TaskManager. + :param method: Method to be validated + :param kwargs: Info for action. + :raises: UnsupportedDriverExtension if 'method' can not be mapped to + the supported interfaces. + :raises: InvalidParameterValue if kwargs does not contain 'method'. + :raises: MissingParameterValue + """ + utils.parse_driver_info(task.node) + + def get_properties(self): + """Return the properties of the interface. + + :returns: dictionary of : entries. + """ + return utils.COMMON_PROPERTIES.copy() + + @base.passthru(['GET'], async_call=False, + description=_('Returns a dictionary, ' + 'containing node boot up sequence, ' + 'in ascending order')) + @utils.handle_ibmc_exception('get iBMC boot up sequence') + def boot_up_seq(self, task, **kwargs): + """List boot type order of the node. + + :param task: A TaskManager instance containing the node to act on. + :param kwargs: Not used. + :raises: InvalidParameterValue if kwargs does not contain 'method'. + :raises: MissingParameterValue + :raises: IBMCConnectionError when it fails to connect to iBMC + :raises: IBMCError when iBMC responses an error information + :returns: A dictionary, containing node boot up sequence, + in ascending order. + """ + driver_info = utils.parse_driver_info(task.node) + with ibmc_client.connect(**driver_info) as conn: + system = conn.system.get() + boot_sequence = system.boot_sequence + return {'boot_up_sequence': boot_sequence} diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index 2d44efa1a2..4c7354ef28 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -671,3 +671,12 @@ def create_test_deploy_template(**kw): if 'id' not in kw_step: del template_step['id'] return dbapi.create_deploy_template(template) + + +def get_test_ibmc_info(): + return { + "ibmc_address": "https://example.com", + "ibmc_username": "username", + "ibmc_password": "password", + "verify_ca": False, + } diff --git a/ironic/tests/unit/drivers/modules/ibmc/__init__.py b/ironic/tests/unit/drivers/modules/ibmc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/drivers/modules/ibmc/base.py b/ironic/tests/unit/drivers/modules/ibmc/base.py new file mode 100644 index 0000000000..cb337207e5 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/ibmc/base.py @@ -0,0 +1,42 @@ +# +# 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 base class for iBMC Driver.""" + +import mock + +from ironic.drivers.modules.ibmc import utils +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.db import utils as db_utils +from ironic.tests.unit.objects import utils as obj_utils + + +class IBMCTestCase(db_base.DbTestCase): + + def setUp(self): + super(IBMCTestCase, self).setUp() + self.driver_info = db_utils.get_test_ibmc_info() + self.config(enabled_hardware_types=['ibmc'], + enabled_power_interfaces=['ibmc'], + enabled_management_interfaces=['ibmc'], + enabled_vendor_interfaces=['ibmc']) + self.node = obj_utils.create_test_node( + self.context, driver='ibmc', driver_info=self.driver_info) + self.ibmc = utils.parse_driver_info(self.node) + + @staticmethod + def mock_ibmc_conn(ibmc_client_connect): + conn = mock.Mock(system=mock.PropertyMock()) + conn.__enter__ = mock.Mock(return_value=conn) + conn.__exit__ = mock.Mock(return_value=None) + ibmc_client_connect.return_value = conn + return conn diff --git a/ironic/tests/unit/drivers/modules/ibmc/test_management.py b/ironic/tests/unit/drivers/modules/ibmc/test_management.py new file mode 100644 index 0000000000..d45a233047 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/ibmc/test_management.py @@ -0,0 +1,276 @@ +# +# 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 iBMC Management interface.""" + +import itertools + +import mock +from oslo_utils import importutils + +from ironic.common import boot_devices +from ironic.common import boot_modes +from ironic.common import exception +from ironic.conductor import task_manager +from ironic.drivers.modules.ibmc import mappings +from ironic.drivers.modules.ibmc import utils +from ironic.tests.unit.drivers.modules.ibmc import base + +constants = importutils.try_import('ibmc_client.constants') +ibmc_client = importutils.try_import('ibmc_client') +ibmc_error = importutils.try_import('ibmc_client.exceptions') + + +class IBMCManagementTestCase(base.IBMCTestCase): + + def test_get_properties(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + properties = task.driver.get_properties() + for prop in utils.COMMON_PROPERTIES: + self.assertIn(prop, properties) + + @mock.patch.object(utils, 'parse_driver_info', autospec=True) + def test_validate(self, mock_parse_driver_info): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.driver.management.validate(task) + mock_parse_driver_info.assert_called_once_with(task.node) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_get_supported_boot_devices(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + # mock return value + _supported_boot_devices = list(mappings.GET_BOOT_DEVICE_MAP) + conn.system.get.return_value = mock.Mock( + boot_source_override=mock.Mock( + supported_boot_devices=_supported_boot_devices + ) + ) + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + supported_boot_devices = ( + task.driver.management.get_supported_boot_devices(task)) + connect_ibmc.assert_called_once_with(**self.ibmc) + expect = sorted(list(mappings.GET_BOOT_DEVICE_MAP.values())) + self.assertEqual(expect, sorted(supported_boot_devices)) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_set_boot_device(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + # mock return value + conn.system.set_boot_source.return_value = None + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + device_mapping = [ + (boot_devices.PXE, constants.BOOT_SOURCE_TARGET_PXE), + (boot_devices.DISK, constants.BOOT_SOURCE_TARGET_HDD), + (boot_devices.CDROM, constants.BOOT_SOURCE_TARGET_CD), + (boot_devices.BIOS, + constants.BOOT_SOURCE_TARGET_BIOS_SETUP), + ('floppy', constants.BOOT_SOURCE_TARGET_FLOPPY), + ] + + persistent_mapping = [ + (True, constants.BOOT_SOURCE_ENABLED_CONTINUOUS), + (False, constants.BOOT_SOURCE_ENABLED_ONCE) + ] + + data_source = list(itertools.product(device_mapping, + persistent_mapping)) + for (device, persistent) in data_source: + task.driver.management.set_boot_device( + task, device[0], persistent=persistent[0]) + connect_ibmc.assert_called_once_with(**self.ibmc) + conn.system.set_boot_source.assert_called_once_with( + device[1], + enabled=persistent[1]) + # Reset mocks + connect_ibmc.reset_mock() + conn.system.set_boot_source.reset_mock() + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_set_boot_device_fail(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + # mock return value + conn.system.set_boot_source.side_effect = ( + ibmc_error.IBMCClientError + ) + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.assertRaisesRegex( + exception.IBMCError, 'set iBMC boot device', + task.driver.management.set_boot_device, task, + boot_devices.PXE) + connect_ibmc.assert_called_once_with(**self.ibmc) + conn.system.set_boot_source.assert_called_once_with( + constants.BOOT_SOURCE_TARGET_PXE, + enabled=constants.BOOT_SOURCE_ENABLED_ONCE) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_get_boot_device(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + # mock return value + conn.system.get.return_value = mock.Mock( + boot_source_override=mock.Mock( + target=constants.BOOT_SOURCE_TARGET_PXE, + enabled=constants.BOOT_SOURCE_ENABLED_CONTINUOUS + ) + ) + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + result_boot_device = task.driver.management.get_boot_device(task) + conn.system.get.assert_called_once() + connect_ibmc.assert_called_once_with(**self.ibmc) + expected = {'boot_device': boot_devices.PXE, + 'persistent': True} + self.assertEqual(expected, result_boot_device) + + def test_get_supported_boot_modes(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + supported_boot_modes = ( + task.driver.management.get_supported_boot_modes(task)) + self.assertEqual(list(mappings.SET_BOOT_MODE_MAP), + supported_boot_modes) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_set_boot_mode(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + # mock system boot source override return value + conn.system.get.return_value = mock.Mock( + boot_source_override=mock.Mock( + target=constants.BOOT_SOURCE_TARGET_PXE, + enabled=constants.BOOT_SOURCE_ENABLED_CONTINUOUS + ) + ) + conn.system.set_boot_source.return_value = None + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + expected_values = [ + (boot_modes.LEGACY_BIOS, constants.BOOT_SOURCE_MODE_BIOS), + (boot_modes.UEFI, constants.BOOT_SOURCE_MODE_UEFI) + ] + + for ironic_boot_mode, ibmc_boot_mode in expected_values: + task.driver.management.set_boot_mode(task, + mode=ironic_boot_mode) + + conn.system.get.assert_called_once() + connect_ibmc.assert_called_once_with(**self.ibmc) + + conn.system.set_boot_source.assert_called_once_with( + constants.BOOT_SOURCE_TARGET_PXE, + enabled=constants.BOOT_SOURCE_ENABLED_CONTINUOUS, + mode=ibmc_boot_mode) + + # Reset + connect_ibmc.reset_mock() + conn.system.set_boot_source.reset_mock() + conn.system.get.reset_mock() + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_set_boot_mode_fail(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + # mock system boot source override return value + conn.system.get.return_value = mock.Mock( + boot_source_override=mock.Mock( + target=constants.BOOT_SOURCE_TARGET_PXE, + enabled=constants.BOOT_SOURCE_ENABLED_CONTINUOUS + ) + ) + conn.system.set_boot_source.side_effect = ( + ibmc_error.IBMCClientError + ) + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + expected_values = [ + (boot_modes.LEGACY_BIOS, constants.BOOT_SOURCE_MODE_BIOS), + (boot_modes.UEFI, constants.BOOT_SOURCE_MODE_UEFI) + ] + + for ironic_boot_mode, ibmc_boot_mode in expected_values: + self.assertRaisesRegex( + exception.IBMCError, 'set iBMC boot mode', + task.driver.management.set_boot_mode, task, + ironic_boot_mode) + + conn.system.set_boot_source.assert_called_once_with( + constants.BOOT_SOURCE_TARGET_PXE, + enabled=constants.BOOT_SOURCE_ENABLED_CONTINUOUS, + mode=ibmc_boot_mode) + + conn.system.get.assert_called_once() + connect_ibmc.assert_called_once_with(**self.ibmc) + + # Reset + connect_ibmc.reset_mock() + conn.system.set_boot_source.reset_mock() + conn.system.get.reset_mock() + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_get_boot_mode(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + # mock system boot source override return value + conn.system.get.return_value = mock.Mock( + boot_source_override=mock.Mock( + target=constants.BOOT_SOURCE_TARGET_PXE, + enabled=constants.BOOT_SOURCE_ENABLED_CONTINUOUS, + mode=constants.BOOT_SOURCE_MODE_BIOS, + ) + ) + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + response = task.driver.management.get_boot_mode(task) + + conn.system.get.assert_called_once() + connect_ibmc.assert_called_once_with(**self.ibmc) + + expected = boot_modes.LEGACY_BIOS + self.assertEqual(expected, response) + + def test_get_sensors_data(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertRaises(NotImplementedError, + task.driver.management.get_sensors_data, task) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_inject_nmi(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + # mock system boot source override return value + conn.system.reset.return_value = None + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.management.inject_nmi(task) + + connect_ibmc.assert_called_once_with(**self.ibmc) + conn.system.reset.assert_called_once_with(constants.RESET_NMI) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_inject_nmi_fail(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + # mock system boot source override return value + conn.system.reset.side_effect = ( + ibmc_error.IBMCClientError + ) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.assertRaisesRegex( + exception.IBMCError, 'inject iBMC NMI', + task.driver.management.inject_nmi, task) + + connect_ibmc.assert_called_once_with(**self.ibmc) + conn.system.reset.assert_called_once_with(constants.RESET_NMI) diff --git a/ironic/tests/unit/drivers/modules/ibmc/test_power.py b/ironic/tests/unit/drivers/modules/ibmc/test_power.py new file mode 100644 index 0000000000..39c5c78d95 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/ibmc/test_power.py @@ -0,0 +1,284 @@ +# +# 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 iBMC Power interface.""" + +import mock +from oslo_utils import importutils + +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers.modules.ibmc import mappings +from ironic.drivers.modules.ibmc import utils +from ironic.tests.unit.drivers.modules.ibmc import base + +constants = importutils.try_import('ibmc_client.constants') +ibmc_client = importutils.try_import('ibmc_client') +ibmc_error = importutils.try_import('ibmc_client.exceptions') + + +@mock.patch('eventlet.greenthread.sleep', lambda _t: None) +class IBMCPowerTestCase(base.IBMCTestCase): + + def test_get_properties(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + properties = task.driver.get_properties() + for prop in utils.COMMON_PROPERTIES: + self.assertIn(prop, properties) + + @mock.patch.object(utils, 'parse_driver_info', autospec=True) + def test_validate(self, mock_parse_driver_info): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.driver.power.validate(task) + mock_parse_driver_info.assert_called_once_with(task.node) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_get_power_state(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + expected_values = mappings.GET_POWER_STATE_MAP + for current, expected in expected_values.items(): + # Mock + conn.system.get.return_value = mock.Mock( + power_state=current + ) + + # Asserts + self.assertEqual(expected, + task.driver.power.get_power_state(task)) + + conn.system.get.assert_called_once() + connect_ibmc.assert_called_once_with(**self.ibmc) + + # Reset Mock + conn.system.get.reset_mock() + connect_ibmc.reset_mock() + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_set_power_state(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + state_mapping = mappings.SET_POWER_STATE_MAP + for (expect_state, reset_type) in state_mapping.items(): + if expect_state in (states.POWER_OFF, states.SOFT_POWER_OFF): + final = constants.SYSTEM_POWER_STATE_OFF + transient = constants.SYSTEM_POWER_STATE_ON + else: + final = constants.SYSTEM_POWER_STATE_ON + transient = constants.SYSTEM_POWER_STATE_OFF + + # Mocks + mock_system_get_results = ( + [mock.Mock(power_state=transient)] * 3 + + [mock.Mock(power_state=final)]) + conn.system.get.side_effect = mock_system_get_results + + task.driver.power.set_power_state(task, expect_state) + + # Asserts + connect_ibmc.assert_called_with(**self.ibmc) + conn.system.reset.assert_called_once_with(reset_type) + self.assertEqual(4, conn.system.get.call_count) + + # Reset Mocks + # TODO(Qianbiao.NG) why reset_mock does not reset call_count + connect_ibmc.reset_mock() + conn.system.get.reset_mock() + conn.system.reset.reset_mock() + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_set_power_state_not_reached(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.config(power_state_change_timeout=2, group='conductor') + + state_mapping = mappings.SET_POWER_STATE_MAP + for (expect_state, reset_type) in state_mapping.items(): + if expect_state in (states.POWER_OFF, states.SOFT_POWER_OFF): + final = constants.SYSTEM_POWER_STATE_OFF + transient = constants.SYSTEM_POWER_STATE_ON + else: + final = constants.SYSTEM_POWER_STATE_ON + transient = constants.SYSTEM_POWER_STATE_OFF + + # Mocks + mock_system_get_results = ( + [mock.Mock(power_state=transient)] * 5 + + [mock.Mock(power_state=final)]) + conn.system.get.side_effect = mock_system_get_results + + self.assertRaises(exception.PowerStateFailure, + task.driver.power.set_power_state, + task, expect_state) + + # Asserts + connect_ibmc.assert_called_with(**self.ibmc) + conn.system.reset.assert_called_once_with(reset_type) + + # Reset Mocks + connect_ibmc.reset_mock() + conn.system.get.reset_mock() + conn.system.reset.reset_mock() + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_set_power_state_fail(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + + # Mocks + conn.system.reset.side_effect = ( + ibmc_error.IBMCClientError + ) + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + # Asserts + self.assertRaisesRegex( + exception.IBMCError, 'set iBMC power state', + task.driver.power.set_power_state, task, states.POWER_ON) + connect_ibmc.assert_called_with(**self.ibmc) + conn.system.reset.assert_called_once_with(constants.RESET_ON) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_set_power_state_timeout(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.config(power_state_change_timeout=2, group='conductor') + + # Mocks + conn.system.get.side_effect = ( + [mock.Mock(power_state=constants.SYSTEM_POWER_STATE_OFF)] * 3 + ) + + # Asserts + self.assertRaisesRegex( + exception.PowerStateFailure, + 'Failed to set node power state to power on', + task.driver.power.set_power_state, task, states.POWER_ON) + + connect_ibmc.assert_called_with(**self.ibmc) + conn.system.reset.assert_called_once_with(constants.RESET_ON) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_reboot(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.config(power_state_change_timeout=2, group='conductor') + expected_values = [ + (constants.SYSTEM_POWER_STATE_OFF, constants.RESET_ON), + (constants.SYSTEM_POWER_STATE_ON, + constants.RESET_FORCE_RESTART) + ] + + # for (expect_state, reset_type) in state_mapping.items(): + for current, reset_type in expected_values: + mock_system_get_results = [ + # Initial state + mock.Mock(power_state=current), + # Transient state - powering off + mock.Mock(power_state=constants.SYSTEM_POWER_STATE_OFF), + # Final state - down powering off + mock.Mock(power_state=constants.SYSTEM_POWER_STATE_ON) + ] + conn.system.get.side_effect = mock_system_get_results + + task.driver.power.reboot(task) + + # Asserts + connect_ibmc.assert_called_with(**self.ibmc) + conn.system.reset.assert_called_once_with(reset_type) + + # Reset Mocks + connect_ibmc.reset_mock() + conn.system.get.reset_mock() + conn.system.reset.reset_mock() + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_reboot_not_reached(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.config(power_state_change_timeout=2, group='conductor') + + # Mocks + conn.system.get.return_value = mock.Mock( + power_state=constants.SYSTEM_POWER_STATE_OFF) + self.assertRaisesRegex( + exception.PowerStateFailure, + 'Failed to set node power state to power on', + task.driver.power.reboot, task) + + # Asserts + connect_ibmc.assert_called_with(**self.ibmc) + conn.system.reset.assert_called_once_with(constants.RESET_ON) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_reboot_fail(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + + # Mocks + conn.system.reset.side_effect = ( + ibmc_error.IBMCClientError + ) + conn.system.get.return_value = mock.Mock( + power_state=constants.SYSTEM_POWER_STATE_ON + ) + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + # Asserts + self.assertRaisesRegex( + exception.IBMCError, 'reboot iBMC', + task.driver.power.reboot, task) + connect_ibmc.assert_called_with(**self.ibmc) + conn.system.get.assert_called_once() + conn.system.reset.assert_called_once_with( + constants.RESET_FORCE_RESTART) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_reboot_timeout(self, connect_ibmc): + conn = self.mock_ibmc_conn(connect_ibmc) + + # Mocks + conn.system.get.side_effect = [mock.Mock( + power_state=constants.SYSTEM_POWER_STATE_OFF + )] * 5 + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.config(power_state_change_timeout=2, group='conductor') + + # Asserts + self.assertRaisesRegex( + exception.PowerStateFailure, + 'Failed to set node power state to power on', + task.driver.power.reboot, task) + + # Asserts + connect_ibmc.assert_called_with(**self.ibmc) + conn.system.reset.assert_called_once_with( + constants.RESET_ON) + + def test_get_supported_power_states(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + supported_power_states = ( + task.driver.power.get_supported_power_states(task)) + self.assertEqual(sorted(list(mappings.SET_POWER_STATE_MAP)), + sorted(supported_power_states)) diff --git a/ironic/tests/unit/drivers/modules/ibmc/test_utils.py b/ironic/tests/unit/drivers/modules/ibmc/test_utils.py new file mode 100644 index 0000000000..7863ced5b1 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/ibmc/test_utils.py @@ -0,0 +1,172 @@ +# +# 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 iBMC Driver common utils.""" + +import copy +import os + +import mock +from oslo_utils import importutils + +from ironic.common import exception +from ironic.conductor import task_manager +from ironic.drivers.modules.ibmc import utils +from ironic.tests.unit.drivers.modules.ibmc import base + +constants = importutils.try_import('ibmc_client.constants') +ibmc_client = importutils.try_import('ibmc_client') +ibmc_error = importutils.try_import('ibmc_client.exceptions') + + +class IBMCUtilsTestCase(base.IBMCTestCase): + + def setUp(self): + super(IBMCUtilsTestCase, self).setUp() + # Redfish specific configurations + self.config(connection_attempts=2, group='ibmc') + self.parsed_driver_info = { + 'address': 'https://example.com', + 'username': 'username', + 'password': 'password', + 'verify_ca': True, + } + + def test_parse_driver_info(self): + response = utils.parse_driver_info(self.node) + self.assertEqual(self.parsed_driver_info, response) + + def test_parse_driver_info_default_scheme(self): + self.node.driver_info['ibmc_address'] = 'example.com' + response = utils.parse_driver_info(self.node) + self.assertEqual(self.parsed_driver_info, response) + + def test_parse_driver_info_default_scheme_with_port(self): + self.node.driver_info['ibmc_address'] = 'example.com:42' + self.parsed_driver_info['address'] = 'https://example.com:42' + response = utils.parse_driver_info(self.node) + self.assertEqual(self.parsed_driver_info, response) + + def test_parse_driver_info_missing_info(self): + for prop in utils.REQUIRED_PROPERTIES: + self.node.driver_info = self.driver_info.copy() + self.node.driver_info.pop(prop) + self.assertRaises(exception.MissingParameterValue, + utils.parse_driver_info, self.node) + + def test_parse_driver_info_invalid_address(self): + for value in ['/banana!', '#location', '?search=hello']: + self.node.driver_info['ibmc_address'] = value + self.assertRaisesRegex(exception.InvalidParameterValue, + 'Invalid iBMC address', + utils.parse_driver_info, self.node) + + @mock.patch.object(os.path, 'isdir', autospec=True) + def test_parse_driver_info_path_verify_ca(self, + mock_isdir): + mock_isdir.return_value = True + fake_path = '/path/to/a/valid/CA' + self.node.driver_info['ibmc_verify_ca'] = fake_path + self.parsed_driver_info['verify_ca'] = fake_path + + response = utils.parse_driver_info(self.node) + self.assertEqual(self.parsed_driver_info, response) + mock_isdir.assert_called_once_with(fake_path) + + @mock.patch.object(os.path, 'isfile', autospec=True) + def test_parse_driver_info_valid_capath(self, mock_isfile): + mock_isfile.return_value = True + fake_path = '/path/to/a/valid/CA.pem' + self.node.driver_info['ibmc_verify_ca'] = fake_path + self.parsed_driver_info['verify_ca'] = fake_path + + response = utils.parse_driver_info(self.node) + self.assertEqual(self.parsed_driver_info, response) + mock_isfile.assert_called_once_with(fake_path) + + def test_parse_driver_info_invalid_value_verify_ca(self): + # Integers are not supported + self.node.driver_info['ibmc_verify_ca'] = 123456 + self.assertRaisesRegex(exception.InvalidParameterValue, + 'Invalid value type', + utils.parse_driver_info, self.node) + + def test_parse_driver_info_valid_string_value_verify_ca(self): + for value in ('0', 'f', 'false', 'off', 'n', 'no'): + self.node.driver_info['ibmc_verify_ca'] = value + response = utils.parse_driver_info(self.node) + parsed_driver_info = copy.deepcopy(self.parsed_driver_info) + parsed_driver_info['verify_ca'] = False + self.assertEqual(parsed_driver_info, response) + + for value in ('1', 't', 'true', 'on', 'y', 'yes'): + self.node.driver_info['ibmc_verify_ca'] = value + response = utils.parse_driver_info(self.node) + self.assertEqual(self.parsed_driver_info, response) + + def test_parse_driver_info_invalid_string_value_verify_ca(self): + for value in ('xyz', '*', '!123', '123'): + self.node.driver_info['ibmc_verify_ca'] = value + self.assertRaisesRegex(exception.InvalidParameterValue, + 'The value should be a Boolean', + utils.parse_driver_info, self.node) + + def test_revert_dictionary(self): + data = { + "key1": "value1", + "key2": "value2" + } + + revert = utils.revert_dictionary(data) + self.assertEqual({ + "value1": "key1", + "value2": "key2" + }, revert) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_handle_ibmc_exception_retry(self, connect_ibmc): + + @utils.handle_ibmc_exception('get IBMC system') + def get_ibmc_system(_task): + driver_info = utils.parse_driver_info(_task.node) + with ibmc_client.connect(**driver_info) as _conn: + return _conn.system.get() + + conn = self.mock_ibmc_conn(connect_ibmc) + # Mocks + conn.system.get.side_effect = [ + ibmc_error.ConnectionError(url=self.ibmc['address'], + error='Failed to connect to host'), + mock.PropertyMock( + boot_source_override=mock.PropertyMock( + target=constants.BOOT_SOURCE_TARGET_PXE, + enabled=constants.BOOT_SOURCE_ENABLED_CONTINUOUS + ) + ) + ] + + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + system = get_ibmc_system(task) + + # Asserts + self.assertEqual(constants.BOOT_SOURCE_TARGET_PXE, + system.boot_source_override.target) + self.assertEqual(constants.BOOT_SOURCE_ENABLED_CONTINUOUS, + system.boot_source_override.enabled) + + # 1 failed, 1 succeed + connect_ibmc.assert_called_with(**self.ibmc) + self.assertEqual(2, connect_ibmc.call_count) + + # 1 failed, 1 succeed + self.assertEqual(2, conn.system.get.call_count) diff --git a/ironic/tests/unit/drivers/modules/ibmc/test_vendor.py b/ironic/tests/unit/drivers/modules/ibmc/test_vendor.py new file mode 100644 index 0000000000..3a316e0d8f --- /dev/null +++ b/ironic/tests/unit/drivers/modules/ibmc/test_vendor.py @@ -0,0 +1,60 @@ +# +# 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 iBMC vendor interface.""" + +import mock +from oslo_utils import importutils + +from ironic.conductor import task_manager +from ironic.drivers.modules.ibmc import utils +from ironic.tests.unit.drivers.modules.ibmc import base + +ibmc_client = importutils.try_import('ibmc_client') + + +@mock.patch('eventlet.greenthread.sleep', lambda _t: None) +class IBMCVendorTestCase(base.IBMCTestCase): + + def setUp(self): + super(IBMCVendorTestCase, self).setUp() + + def test_get_properties(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + properties = task.driver.get_properties() + for prop in utils.COMMON_PROPERTIES: + self.assertIn(prop, properties) + + @mock.patch.object(utils, 'parse_driver_info', autospec=True) + def test_validate(self, mock_parse_driver_info): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.driver.power.validate(task) + mock_parse_driver_info.assert_called_once_with(task.node) + + @mock.patch.object(ibmc_client, 'connect', autospec=True) + def test_list_boot_type_order(self, connect_ibmc): + # Mocks + conn = self.mock_ibmc_conn(connect_ibmc) + boot_up_seq = ['Pxe', 'Hdd', 'Others', 'Cd'] + conn.system.get.return_value = mock.Mock( + boot_sequence=['Pxe', 'Hdd', 'Others', 'Cd'] + ) + + expected = {'boot_up_sequence': boot_up_seq} + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + seq = task.driver.vendor.boot_up_seq(task) + conn.system.get.assert_called_once() + connect_ibmc.assert_called_once_with(**self.ibmc) + self.assertEqual(expected, seq) diff --git a/ironic/tests/unit/drivers/test_ibmc.py b/ironic/tests/unit/drivers/test_ibmc.py new file mode 100644 index 0000000000..731311b544 --- /dev/null +++ b/ironic/tests/unit/drivers/test_ibmc.py @@ -0,0 +1,47 @@ +# +# 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. + +# Version 1.0.0 + +from ironic.conductor import task_manager +from ironic.drivers.modules.ibmc import management as ibmc_mgmt +from ironic.drivers.modules.ibmc import power as ibmc_power +from ironic.drivers.modules.ibmc import vendor as ibmc_vendor +from ironic.drivers.modules import iscsi_deploy +from ironic.drivers.modules import noop +from ironic.drivers.modules import pxe +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as obj_utils + + +class IBMCHardwareTestCase(db_base.DbTestCase): + + def setUp(self): + super(IBMCHardwareTestCase, self).setUp() + self.config(enabled_hardware_types=['ibmc'], + enabled_power_interfaces=['ibmc'], + enabled_management_interfaces=['ibmc'], + enabled_vendor_interfaces=['ibmc']) + + def test_default_interfaces(self): + node = obj_utils.create_test_node(self.context, driver='ibmc') + with task_manager.acquire(self.context, node.id) as task: + self.assertIsInstance(task.driver.management, + ibmc_mgmt.IBMCManagement) + self.assertIsInstance(task.driver.power, + ibmc_power.IBMCPower) + self.assertIsInstance(task.driver.boot, pxe.PXEBoot) + self.assertIsInstance(task.driver.deploy, iscsi_deploy.ISCSIDeploy) + self.assertIsInstance(task.driver.console, noop.NoConsole) + self.assertIsInstance(task.driver.raid, noop.NoRAID) + self.assertIsInstance(task.driver.vendor, ibmc_vendor.IBMCVendor) diff --git a/ironic/tests/unit/drivers/third_party_driver_mock_specs.py b/ironic/tests/unit/drivers/third_party_driver_mock_specs.py index 6a5f906af7..49660c4add 100644 --- a/ironic/tests/unit/drivers/third_party_driver_mock_specs.py +++ b/ironic/tests/unit/drivers/third_party_driver_mock_specs.py @@ -170,3 +170,10 @@ XCLARITY_STATES_SPEC = ( 'STATE_POWER_OFF', 'STATE_POWER_ON', ) + +# python-ibmcclient +IBMCCLIENT_SPEC = ( + 'connect', + 'exceptions', + 'constants', +) diff --git a/ironic/tests/unit/drivers/third_party_driver_mocks.py b/ironic/tests/unit/drivers/third_party_driver_mocks.py index db82dfe7bb..6e6b188de7 100644 --- a/ironic/tests/unit/drivers/third_party_driver_mocks.py +++ b/ironic/tests/unit/drivers/third_party_driver_mocks.py @@ -26,6 +26,7 @@ Current list of mocked libraries: - pysnmp - scciclient - python-dracclient +- python-ibmcclient """ import sys @@ -263,3 +264,45 @@ if not xclarity_client: xclarity_client.exceptions.XClarityException = type('XClarityException', (Exception,), {}) sys.modules['xclarity_client.models'] = xclarity_client.models + + +# python-ibmcclient mocks for HUAWEI rack server driver +ibmc_client = importutils.try_import('ibmc_client') +if not ibmc_client: + ibmc_client = mock.MagicMock(spec_set=mock_specs.IBMCCLIENT_SPEC) + sys.modules['ibmc_client'] = ibmc_client + + # Mock iBMC client exceptions + exceptions = mock.MagicMock() + exceptions.ConnectionError = ( + type('ConnectionError', (MockKwargsException,), {})) + exceptions.IBMCClientError = ( + type('IBMCClientError', (MockKwargsException,), {})) + sys.modules['ibmc_client.exceptions'] = exceptions + + # Mock iIBMC client constants + constants = mock.MagicMock( + SYSTEM_POWER_STATE_ON='On', + SYSTEM_POWER_STATE_OFF='Off', + BOOT_SOURCE_TARGET_NONE='None', + BOOT_SOURCE_TARGET_PXE='Pxe', + BOOT_SOURCE_TARGET_FLOPPY='Floppy', + BOOT_SOURCE_TARGET_CD='Cd', + BOOT_SOURCE_TARGET_HDD='Hdd', + BOOT_SOURCE_TARGET_BIOS_SETUP='BiosSetup', + BOOT_SOURCE_MODE_BIOS='Legacy', + BOOT_SOURCE_MODE_UEFI='UEFI', + BOOT_SOURCE_ENABLED_ONCE='Once', + BOOT_SOURCE_ENABLED_CONTINUOUS='Continuous', + BOOT_SOURCE_ENABLED_DISABLED='Disabled', + RESET_NMI='Nmi', + RESET_ON='On', + RESET_FORCE_OFF='ForceOff', + RESET_GRACEFUL_SHUTDOWN='GracefulShutdown', + RESET_FORCE_RESTART='ForceRestart', + RESET_FORCE_POWER_CYCLE='ForcePowerCycle') + sys.modules['ibmc_client.constants'] = constants + + if 'ironic.drivers.modules.ibmc' in sys.modules: + six.moves.reload_module( + sys.modules['ironic.drivers.modules.ibmc']) diff --git a/releasenotes/notes/ibmc-driver-45fcf9f50ebf0193.yaml b/releasenotes/notes/ibmc-driver-45fcf9f50ebf0193.yaml new file mode 100644 index 0000000000..f58c4bb4d6 --- /dev/null +++ b/releasenotes/notes/ibmc-driver-45fcf9f50ebf0193.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Adds a new hardware type ``ibmc`` for HUAWEI 2288H V5, CH121 V5 series + servers. This hardware type supports PXE based boot using HUAWEI iBMC + RESTful APIs. The following driver interfaces are supported: + + * management: ``ibmc`` + * power: ``ibmc`` + * vendor: ``ibmc`` diff --git a/setup.cfg b/setup.cfg index cfab34dd3f..7638c911fb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -95,6 +95,7 @@ ironic.hardware.interfaces.inspect = ironic.hardware.interfaces.management = cimc = ironic.drivers.modules.cimc.management:CIMCManagement fake = ironic.drivers.modules.fake:FakeManagement + ibmc = ironic.drivers.modules.ibmc.management:IBMCManagement idrac = ironic.drivers.modules.drac.management:DracManagement ilo = ironic.drivers.modules.ilo.management:IloManagement ipmitool = ironic.drivers.modules.ipmitool:IPMIManagement @@ -112,6 +113,7 @@ ironic.hardware.interfaces.network = ironic.hardware.interfaces.power = cimc = ironic.drivers.modules.cimc.power:Power fake = ironic.drivers.modules.fake:FakePower + ibmc = ironic.drivers.modules.ibmc.power:IBMCPower idrac = ironic.drivers.modules.drac.power:DracPower ilo = ironic.drivers.modules.ilo.power:IloPower ipmitool = ironic.drivers.modules.ipmitool:IPMIPower @@ -142,6 +144,7 @@ ironic.hardware.interfaces.storage = ironic.hardware.interfaces.vendor = fake = ironic.drivers.modules.fake:FakeVendorB + ibmc = ironic.drivers.modules.ibmc.vendor:IBMCVendor idrac = ironic.drivers.modules.drac.vendor_passthru:DracVendorPassthru ilo = ironic.drivers.modules.ilo.vendor:VendorPassthru ipmitool = ironic.drivers.modules.ipmitool:VendorPassthru @@ -151,6 +154,7 @@ ironic.hardware.types = cisco-ucs-managed = ironic.drivers.cisco_ucs:CiscoUCSManaged cisco-ucs-standalone = ironic.drivers.cisco_ucs:CiscoUCSStandalone fake-hardware = ironic.drivers.fake_hardware:FakeHardware + ibmc = ironic.drivers.ibmc:IBMCHardware idrac = ironic.drivers.drac:IDRACHardware ilo = ironic.drivers.ilo:IloHardware ilo5 = ironic.drivers.ilo:Ilo5Hardware