From f1f4f892fe95f95128f8b872d4a3d32dc9cdb5fd Mon Sep 17 00:00:00 2001 From: Qianbiao NG Date: Wed, 20 Feb 2019 10:56:04 +0800 Subject: [PATCH] Add Huawei iBMC driver support This patch proposes to adding iBMC driver for deploying the Huawei 2288H V5, CH121 V5 series servers. The driver aims to add management and power interfaces using Huawei iBMC RESTful APIs for those series servers. Change-Id: Ic5e920e4e58811c6a6dfe927732595950aea64e7 Story: 2004635 Task: 28566 --- doc/source/admin/drivers.rst | 1 + doc/source/admin/drivers/ibmc.rst | 119 ++ .../ironic_standalone_with_ibmc_driver.svg | 1309 +++++++++++++++++ driver-requirements.txt | 3 + ironic/common/exception.py | 8 + ironic/conf/__init__.py | 2 + ironic/conf/ibmc.py | 35 + ironic/drivers/ibmc.py | 40 + ironic/drivers/modules/ibmc/__init__.py | 0 ironic/drivers/modules/ibmc/management.py | 237 +++ ironic/drivers/modules/ibmc/mappings.py | 70 + ironic/drivers/modules/ibmc/power.py | 145 ++ ironic/drivers/modules/ibmc/utils.py | 177 +++ ironic/drivers/modules/ibmc/vendor.py | 87 ++ ironic/tests/unit/db/utils.py | 9 + .../unit/drivers/modules/ibmc/__init__.py | 0 .../tests/unit/drivers/modules/ibmc/base.py | 42 + .../drivers/modules/ibmc/test_management.py | 276 ++++ .../unit/drivers/modules/ibmc/test_power.py | 284 ++++ .../unit/drivers/modules/ibmc/test_utils.py | 172 +++ .../unit/drivers/modules/ibmc/test_vendor.py | 60 + ironic/tests/unit/drivers/test_ibmc.py | 47 + .../drivers/third_party_driver_mock_specs.py | 7 + .../unit/drivers/third_party_driver_mocks.py | 43 + .../notes/ibmc-driver-45fcf9f50ebf0193.yaml | 10 + setup.cfg | 4 + 26 files changed, 3187 insertions(+) create mode 100644 doc/source/admin/drivers/ibmc.rst create mode 100644 doc/source/images/ironic_standalone_with_ibmc_driver.svg create mode 100644 ironic/conf/ibmc.py create mode 100644 ironic/drivers/ibmc.py create mode 100644 ironic/drivers/modules/ibmc/__init__.py create mode 100644 ironic/drivers/modules/ibmc/management.py create mode 100644 ironic/drivers/modules/ibmc/mappings.py create mode 100644 ironic/drivers/modules/ibmc/power.py create mode 100644 ironic/drivers/modules/ibmc/utils.py create mode 100644 ironic/drivers/modules/ibmc/vendor.py create mode 100644 ironic/tests/unit/drivers/modules/ibmc/__init__.py create mode 100644 ironic/tests/unit/drivers/modules/ibmc/base.py create mode 100644 ironic/tests/unit/drivers/modules/ibmc/test_management.py create mode 100644 ironic/tests/unit/drivers/modules/ibmc/test_power.py create mode 100644 ironic/tests/unit/drivers/modules/ibmc/test_utils.py create mode 100644 ironic/tests/unit/drivers/modules/ibmc/test_vendor.py create mode 100644 ironic/tests/unit/drivers/test_ibmc.py create mode 100644 releasenotes/notes/ibmc-driver-45fcf9f50ebf0193.yaml 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