From 460b9f1f5ffc39e24950f8246d04a760d8631c81 Mon Sep 17 00:00:00 2001 From: Thiago Paiva Date: Mon, 15 Jun 2015 11:09:32 -0300 Subject: [PATCH] OneView Driver for Ironic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This driver that will promote integration between Ironic and the HP OneView Management System. The driver will allow Ironic to borrow non-dedicated servers from OneViews's inventory to provision baremetal instances with minimal common pre-configuration, set through OneView's *Server Profile Templates*. Co-Authored-By: Alberto Barros Co-Authored-By: André Aranha Co-Authored-By: Caio Oliveira Co-Authored-By: Diego Pereira Co-Authored-By: Gabriel Bezerra Co-Authored-By: Lilia Sampaio Co-Authored-By: Sinval Vieira Change-Id: Ic2fb7860e6b4f5183b6525ff7709c7616350a96a Implements: blueprint new-ironic-driver-for-oneview Depends-on: I914596e592477e148e642f93cfbe114464c3fe38 --- driver-requirements.txt | 1 + etc/ironic/ironic.conf.sample | 27 ++ ironic/common/exception.py | 4 + ironic/drivers/modules/oneview/__init__.py | 0 ironic/drivers/modules/oneview/common.py | 259 ++++++++++++++++++ ironic/drivers/modules/oneview/management.py | 172 ++++++++++++ ironic/drivers/modules/oneview/power.py | 133 +++++++++ ironic/drivers/modules/oneview/vendor.py | 113 ++++++++ ironic/drivers/oneview.py | 110 ++++++++ ironic/drivers/utils.py | 26 ++ ironic/tests/unit/db/utils.py | 18 ++ .../unit/drivers/modules/oneview/__init__.py | 0 .../drivers/modules/oneview/test_common.py | 173 ++++++++++++ .../modules/oneview/test_management.py | 184 +++++++++++++ .../drivers/modules/oneview/test_power.py | 192 +++++++++++++ .../drivers/modules/oneview/test_vendor.py | 248 +++++++++++++++++ ironic/tests/unit/drivers/test_utils.py | 65 +++++ .../drivers/third_party_driver_mock_specs.py | 18 ++ .../unit/drivers/third_party_driver_mocks.py | 25 ++ setup.cfg | 3 + 20 files changed, 1771 insertions(+) create mode 100644 ironic/drivers/modules/oneview/__init__.py create mode 100644 ironic/drivers/modules/oneview/common.py create mode 100644 ironic/drivers/modules/oneview/management.py create mode 100644 ironic/drivers/modules/oneview/power.py create mode 100644 ironic/drivers/modules/oneview/vendor.py create mode 100644 ironic/drivers/oneview.py create mode 100644 ironic/tests/unit/drivers/modules/oneview/__init__.py create mode 100644 ironic/tests/unit/drivers/modules/oneview/test_common.py create mode 100644 ironic/tests/unit/drivers/modules/oneview/test_management.py create mode 100644 ironic/tests/unit/drivers/modules/oneview/test_power.py create mode 100644 ironic/tests/unit/drivers/modules/oneview/test_vendor.py diff --git a/driver-requirements.txt b/driver-requirements.txt index c828bff67d..3f7ac24e2b 100644 --- a/driver-requirements.txt +++ b/driver-requirements.txt @@ -8,6 +8,7 @@ proliantutils>=2.1.5 pyghmi>=0.8.0 pysnmp python-ironic-inspector-client +python-oneviewclient>=2.0.0 python-scciclient>=0.2.0 python-seamicroclient>=0.4.0 UcsSdk==0.8.2.2 diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index 30fd4c6f01..9e283c3dd7 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -1413,6 +1413,33 @@ #cleaning_network_uuid= +[oneview] + +# +# Options defined in ironic.drivers.modules.oneview.common +# + +# URL where OneView is available (string value) +#manager_url= + +# OneView username to be used (string value) +#username= + +# OneView password to be used (string value) +#password= + +# Option to allow insecure connection with OneView (boolean +# value) +#allow_insecure_connections=false + +# Path to CA certificate (string value) +#tls_cacert_file= + +# Max connection retries to check changes on OneView (integer +# value) +#max_polling_attempts=20 + + [oslo_concurrency] # diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 9cbfd935f8..3d6a9f9f3c 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -605,3 +605,7 @@ class ImageUploadFailed(IronicException): class CIMCException(IronicException): _msg_fmt = _("Cisco IMC exception occurred for node %(node)s: %(error)s") + + +class OneViewError(IronicException): + _msg_fmt = _("OneView exception occurred. Error: %(error)s") diff --git a/ironic/drivers/modules/oneview/__init__.py b/ironic/drivers/modules/oneview/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/drivers/modules/oneview/common.py b/ironic/drivers/modules/oneview/common.py new file mode 100644 index 0000000000..ab5e49e89a --- /dev/null +++ b/ironic/drivers/modules/oneview/common.py @@ -0,0 +1,259 @@ +# +# Copyright 2015 Hewlett Packard Development Company, LP +# Copyright 2015 Universidade Federal de Campina Grande +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import importutils + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common.i18n import _LE +from ironic.common import states +from ironic.drivers import utils + + +LOG = logging.getLogger(__name__) + +client = importutils.try_import('oneview_client.client') +oneview_states = importutils.try_import('oneview_client.states') +oneview_exceptions = importutils.try_import('oneview_client.exceptions') + +opts = [ + cfg.StrOpt('manager_url', + help=_('URL where OneView is available')), + cfg.StrOpt('username', + help=_('OneView username to be used')), + cfg.StrOpt('password', + secret=True, + help=_('OneView password to be used')), + cfg.BoolOpt('allow_insecure_connections', + default=False, + help=_('Option to allow insecure connection with OneView')), + cfg.StrOpt('tls_cacert_file', + default=None, + help=_('Path to CA certificate')), + cfg.IntOpt('max_polling_attempts', + default=12, + help=_('Max connection retries to check changes on OneView')), +] + +CONF = cfg.CONF +CONF.register_opts(opts, group='oneview') + +REQUIRED_ON_DRIVER_INFO = { + 'server_hardware_uri': _("Server Hardware URI. Required."), + 'server_profile_template_uri': _("Server Profile Template URI to clone " + "from. Required."), +} + +REQUIRED_ON_PROPERTIES = { + 'server_hardware_type_uri': _("Server Hardware Type URI. Required."), +} + +OPTIONAL_ON_PROPERTIES = { + 'enclosure_group_uri': _("Enclosure Group URI.") +} + +COMMON_PROPERTIES = {} +COMMON_PROPERTIES.update(REQUIRED_ON_DRIVER_INFO) +COMMON_PROPERTIES.update(REQUIRED_ON_PROPERTIES) +COMMON_PROPERTIES.update(OPTIONAL_ON_PROPERTIES) + + +def get_oneview_client(): + """Generates an instance of the OneView client. + + Generates an instance of the OneView client using the imported + oneview_client library. + + :returns: an instance of the OneView client + """ + + oneview_client = client.Client( + manager_url=CONF.oneview.manager_url, + username=CONF.oneview.username, + password=CONF.oneview.password, + allow_insecure_connections=CONF.oneview.allow_insecure_connections, + tls_cacert_file=CONF.oneview.tls_cacert_file, + max_polling_attempts=CONF.oneview.max_polling_attempts + ) + return oneview_client + + +def verify_node_info(node): + """Verifies if fields and namespaces of a node are valid. + + Verifies if the 'driver_info' field and the 'properties/capabilities' + namespace exist and are not empty. + + :param: node: node object to be verified + :raises: InvalidParameterValue if required node capabilities and/or + driver_info are malformed or missing + :raises: MissingParameterValue if required node capabilities and/or + driver_info are missing + """ + capabilities_dict = utils.capabilities_to_dict( + node.properties.get('capabilities', '') + ) + driver_info = node.driver_info + + _verify_node_info('properties/capabilities', capabilities_dict, + REQUIRED_ON_PROPERTIES) + _verify_node_info('driver_info', driver_info, + REQUIRED_ON_DRIVER_INFO) + + +def get_oneview_info(node): + """Gets OneView information from the node. + + :param: node: node object to get information from + :returns: a dictionary containing: + :server_hardware_uri: the uri of the server hardware in OneView + :server_hardware_type_uri: the uri of the server hardware type in + OneView + :enclosure_group_uri: the uri of the enclosure group in OneView + :server_profile_template_uri: the uri of the server profile template in + OneView + :raises InvalidParameterValue if node capabilities are malformed + """ + + capabilities_dict = utils.capabilities_to_dict( + node.properties.get('capabilities', '') + ) + + driver_info = node.driver_info + + oneview_info = { + 'server_hardware_uri': + driver_info.get('server_hardware_uri'), + 'server_hardware_type_uri': + capabilities_dict.get('server_hardware_type_uri'), + 'enclosure_group_uri': + capabilities_dict.get('enclosure_group_uri'), + 'server_profile_template_uri': + driver_info.get('server_profile_template_uri'), + } + return oneview_info + + +def validate_oneview_resources_compatibility(task): + """Validates if the node configuration is consistent with OneView. + + This method calls python-oneviewclient functions to validate if the node + configuration is consistent with the OneView resources it represents, + including server_hardware_uri, server_hardware_type_uri, + server_profile_template_uri, enclosure_group_uri and node ports. Also + verifies if a Server Profile is applied to the Server Hardware the node + represents. If any validation fails, python-oneviewclient will raise + an appropriate OneViewException. + + :param: task: a TaskManager instance containing the node to act on. + """ + + node = task.node + node_ports = task.ports + try: + oneview_client = get_oneview_client() + oneview_info = get_oneview_info(node) + + oneview_client.validate_node_server_hardware( + oneview_info, node.properties.get('memory_mb'), + node.properties.get('cpus') + ) + oneview_client.validate_node_server_hardware_type(oneview_info) + oneview_client.check_server_profile_is_applied(oneview_info) + oneview_client.is_node_port_mac_compatible_with_server_profile( + oneview_info, node_ports + ) + oneview_client.validate_node_enclosure_group(oneview_info) + oneview_client.validate_node_server_profile_template(oneview_info) + except oneview_exceptions.OneViewException as oneview_exc: + msg = (_("Error validating node resources with OneView: %s") + % oneview_exc) + LOG.error(msg) + raise exception.OneViewError(error=msg) + + +def translate_oneview_power_state(power_state): + """Translates OneView's power states strings to Ironic's format. + + :param: power_state: power state string to be translated + :returns: the power state translated + """ + + power_states_map = { + oneview_states.ONEVIEW_POWER_ON: states.POWER_ON, + oneview_states.ONEVIEW_POWERING_OFF: states.POWER_ON, + oneview_states.ONEVIEW_POWER_OFF: states.POWER_OFF, + oneview_states.ONEVIEW_POWERING_ON: states.POWER_OFF, + oneview_states.ONEVIEW_RESETTING: states.REBOOT + } + + return power_states_map.get(power_state, states.ERROR) + + +def _verify_node_info(node_namespace, node_info_dict, info_required): + """Verify if info_required is present in node_namespace of the node info. + + """ + missing_keys = set(info_required) - set(node_info_dict) + + if missing_keys: + raise exception.MissingParameterValue( + _("Missing the keys for the following OneView data in node's " + "%(namespace)s: %(missing_keys)s.") % + {'namespace': node_namespace, + 'missing_keys': ', '.join(missing_keys) + } + ) + + # False and 0 can still be considered as valid values + missing_values_keys = [k for k in info_required + if node_info_dict[k] in ('', None)] + if missing_values_keys: + missing_keys = ["%s:%s" % (node_namespace, k) + for k in missing_values_keys] + raise exception.MissingParameterValue( + _("Missing parameter value for: '%s'") % "', '".join(missing_keys) + ) + + +def node_has_server_profile(func): + """Checks if the node's Server Hardware as a Server Profile associated. + + """ + def inner(*args, **kwargs): + task = args[1] + oneview_info = get_oneview_info(task.node) + oneview_client = get_oneview_client() + try: + node_has_server_profile = ( + oneview_client.get_server_profile_from_hardware(oneview_info) + ) + except oneview_exceptions.OneViewException as oneview_exc: + LOG.error( + _LE("Failed to get server profile from OneView appliance for" + "node %(node)s. Error: %(message)s"), + {"node": task.node.uuid, "message": oneview_exc} + ) + raise exception.OneViewError(error=oneview_exc) + if not node_has_server_profile: + raise exception.OperationNotPermitted( + _("A Server Profile is not associated with node %s.") % + task.node.uuid + ) + return func(*args, **kwargs) + return inner diff --git a/ironic/drivers/modules/oneview/management.py b/ironic/drivers/modules/oneview/management.py new file mode 100644 index 0000000000..790246fa9d --- /dev/null +++ b/ironic/drivers/modules/oneview/management.py @@ -0,0 +1,172 @@ +# +# Copyright 2015 Hewlett Packard Development Company, LP +# Copyright 2015 Universidade Federal de Campina Grande +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_log import log as logging +from oslo_utils import importutils + +from ironic.common import boot_devices +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.oneview import common + +LOG = logging.getLogger(__name__) + +BOOT_DEVICE_MAPPING_TO_OV = { + boot_devices.DISK: 'HardDisk', + boot_devices.PXE: 'PXE', + boot_devices.CDROM: 'CD', +} + +BOOT_DEVICE_OV_TO_GENERIC = { + v: k + for k, v in BOOT_DEVICE_MAPPING_TO_OV.items() +} + +oneview_exceptions = importutils.try_import('oneview_client.exceptions') + + +class OneViewManagement(base.ManagementInterface): + + def get_properties(self): + return common.COMMON_PROPERTIES + + def validate(self, task): + """Checks required info on 'driver_info' and validates node with OneView + + Validates whether the 'driver_info' property of the supplied + task's node contains the required info such as server_hardware_uri, + server_hardware_type, server_profile_template_uri and + enclosure_group_uri. Also, checks if the server profile of the node is + applied, if NICs are valid for the server profile of the node, and if + the server hardware attributes (ram, memory, vcpus count) are + consistent with OneView. + + :param task: a task from TaskManager. + :raises: InvalidParameterValue if parameters set are inconsistent with + resources in OneView + """ + + common.verify_node_info(task.node) + + try: + common.validate_oneview_resources_compatibility(task) + except exception.OneViewError as oneview_exc: + raise exception.InvalidParameterValue(oneview_exc) + + def get_supported_boot_devices(self, task): + """Gets a list of the supported boot devices. + + :param task: a task from TaskManager. + :returns: A list with the supported boot devices defined + in :mod:`ironic.common.boot_devices`. + """ + + return sorted(BOOT_DEVICE_MAPPING_TO_OV.keys()) + + @task_manager.require_exclusive_lock + @common.node_has_server_profile + def set_boot_device(self, task, device, persistent=False): + """Sets the boot device for a node. + + Sets the boot device to use on next reboot of the node. + + :param task: a task from TaskManager. + :param device: the boot device, one of the supported devices + listed in :mod:`ironic.common.boot_devices`. + :param persistent: Boolean value. True if the boot device will + persist to all future boots, False if not. + Default: False. + :raises: InvalidParameterValue if an invalid boot device is + specified. + :raises: OperationNotPermitted if the server has no server profile or + if the server is already powered on. + :raises: OneViewError if the communication with OneView fails + """ + + oneview_info = common.get_oneview_info(task.node) + + if device not in self.get_supported_boot_devices(task): + raise exception.InvalidParameterValue( + _("Invalid boot device %s specified.") % device) + + LOG.debug("Setting boot device to %(device)s for node %(node)s", + {"device": device, "node": task.node.uuid}) + try: + oneview_client = common.get_oneview_client() + device_to_oneview = BOOT_DEVICE_MAPPING_TO_OV.get(device) + oneview_client.set_boot_device(oneview_info, device_to_oneview) + except oneview_exceptions.OneViewException as oneview_exc: + msg = (_( + "Error setting boot device on OneView. Error: %s") + % oneview_exc + ) + LOG.error(msg) + raise exception.OneViewError(error=msg) + + @common.node_has_server_profile + def get_boot_device(self, task): + """Get the current boot device for the task's node. + + Provides the current boot device of the node. + + :param task: a task from TaskManager. + :returns: a dictionary containing: + :boot_device: the boot device, one of + :mod:`ironic.common.boot_devices` [PXE, DISK, CDROM] + :persistent: Whether the boot device will persist to all + future boots or not, None if it is unknown. + :raises: OperationNotPermitted if no Server Profile is associated with + the node + :raises: InvalidParameterValue if the boot device is unknown + :raises: OneViewError if the communication with OneView fails + """ + + oneview_info = common.get_oneview_info(task.node) + + try: + oneview_client = common.get_oneview_client() + boot_order = oneview_client.get_boot_order(oneview_info) + except oneview_exceptions.OneViewException as oneview_exc: + msg = (_( + "Error getting boot device from OneView. Error: %s") + % oneview_exc + ) + LOG.error(msg) + raise exception.OneViewError(msg) + + primary_device = boot_order[0] + if primary_device not in BOOT_DEVICE_OV_TO_GENERIC: + raise exception.InvalidParameterValue( + _("Unsupported boot Device %(device)s for Node: %(node)s") + % {"device": primary_device, "node": task.node.uuid} + ) + + boot_device = { + 'boot_device': BOOT_DEVICE_OV_TO_GENERIC.get(primary_device), + 'persistent': True, + } + + return boot_device + + def get_sensors_data(self, task): + """Get sensors data. + + Not implemented by this driver. + :param task: a TaskManager instance. + """ + raise NotImplementedError() diff --git a/ironic/drivers/modules/oneview/power.py b/ironic/drivers/modules/oneview/power.py new file mode 100644 index 0000000000..917c75206d --- /dev/null +++ b/ironic/drivers/modules/oneview/power.py @@ -0,0 +1,133 @@ +# +# Copyright 2015 Hewlett Packard Development Company, LP +# Copyright 2015 Universidade Federal de Campina Grande +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_log import log as logging +from oslo_utils import importutils + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common.i18n import _LE +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers import base +from ironic.drivers.modules.oneview import common + + +LOG = logging.getLogger(__name__) + +oneview_exceptions = importutils.try_import('oneview_client.exceptions') + + +class OneViewPower(base.PowerInterface): + + def get_properties(self): + return common.COMMON_PROPERTIES + + def validate(self, task): + """Checks required info on 'driver_info' and validates node with OneView + + Validates whether the 'oneview_info' property of the supplied + task's node contains the required info such as server_hardware_uri, + server_hardware_type, server_profile_template_uri and + enclosure_group_uri. Also, checks if the server profile of the node is + applied, if NICs are valid for the server profile of the node, and if + the server hardware attributes (ram, memory, vcpus count) are + consistent with OneView. + + :param task: a task from TaskManager. + :raises: MissingParameterValue if a required parameter is missing. + :raises: InvalidParameterValue if parameters set are inconsistent with + resources in OneView + """ + common.verify_node_info(task.node) + + try: + common.validate_oneview_resources_compatibility(task) + except exception.OneViewError as oneview_exc: + raise exception.InvalidParameterValue(oneview_exc) + + def get_power_state(self, task): + """Gets the current power state. + + :param task: a TaskManager instance. + :param node: The Node. + :returns: one of :mod:`ironic.common.states` POWER_OFF, + POWER_ON or ERROR. + :raises: OneViewError if fails to retrieve power state of OneView + resource + """ + + oneview_info = common.get_oneview_info(task.node) + oneview_client = common.get_oneview_client() + try: + power_state = oneview_client.get_node_power_state(oneview_info) + except oneview_exceptions.OneViewException as oneview_exc: + LOG.error( + _LE("Error getting power state for node %(node)s. Error:" + "%(error)s"), + {'node': task.node.uuid, 'error': oneview_exc} + ) + raise exception.OneViewError(error=oneview_exc) + return common.translate_oneview_power_state(power_state) + + @task_manager.require_exclusive_lock + def set_power_state(self, task, power_state): + """Turn the current power state on or off. + + :param task: a TaskManager instance. + :param node: The Node. + :param power_state: The desired power state POWER_ON, POWER_OFF or + REBOOT from :mod:`ironic.common.states`. + :raises: InvalidParameterValue if an invalid power state was specified. + :raises: PowerStateFailure if the power couldn't be set to power_state. + :raises: OneViewError if OneView fails setting the power state. + """ + + oneview_info = common.get_oneview_info(task.node) + oneview_client = common.get_oneview_client() + + LOG.debug('Setting power state of node %(node_uuid)s to ' + '%(power_state)s', + {'node_uuid': task.node.uuid, 'power_state': power_state}) + + try: + if power_state == states.POWER_ON: + oneview_client.power_on(oneview_info) + elif power_state == states.POWER_OFF: + oneview_client.power_off(oneview_info) + elif power_state == states.REBOOT: + oneview_client.power_off(oneview_info) + oneview_client.power_on(oneview_info) + else: + raise exception.InvalidParameterValue( + _("set_power_state called with invalid power state %s.") + % power_state) + except oneview_exceptions.OneViewException as exc: + raise exception.OneViewError( + _("Error setting power state: %s") % exc + ) + + @task_manager.require_exclusive_lock + def reboot(self, task): + """Reboot the node + + :param task: a TaskManager instance. + :param node: The Node. + :raises: PowerStateFailure if the final state of the node is not + POWER_ON. + """ + + self.set_power_state(task, states.REBOOT) diff --git a/ironic/drivers/modules/oneview/vendor.py b/ironic/drivers/modules/oneview/vendor.py new file mode 100644 index 0000000000..98b0be218b --- /dev/null +++ b/ironic/drivers/modules/oneview/vendor.py @@ -0,0 +1,113 @@ +# +# Copyright 2015 Hewlett Packard Development Company, LP +# Copyright 2015 Universidade Federal de Campina Grande +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_log import log +import retrying + +from ironic.common.i18n import _ +from ironic.common.i18n import _LI +from ironic.common.i18n import _LW +from ironic.common import states +from ironic.conductor import utils as manager_utils +from ironic.drivers.modules import agent +from ironic.drivers.modules import deploy_utils + +LOG = log.getLogger(__name__) +CONF = agent.CONF + + +# NOTE (thiagop): We overwrite this interface because we cannot change the boot +# device of OneView managed blades while they are still powered on. We moved +# the call of node_set_boot_device from reboot_to_instance to +# reboot_and_finish_deploy and changed the behavior to shutdown the node before +# doing it. +# TODO(thiagop): remove this interface once bug/1503855 is fixed +class AgentVendorInterface(agent.AgentVendorInterface): + + def reboot_to_instance(self, task, **kwargs): + task.process_event('resume') + node = task.node + error = self.check_deploy_success(node) + if error is not None: + # TODO(jimrollenhagen) power off if using neutron dhcp to + # align with pxe driver? + msg = (_('node %(node)s command status errored: %(error)s') % + {'node': node.uuid, 'error': error}) + LOG.error(msg) + deploy_utils.set_failed_state(task, msg) + return + + LOG.info(_LI('Image successfully written to node %s'), node.uuid) + LOG.debug('Rebooting node %s to instance', node.uuid) + + self.reboot_and_finish_deploy(task) + + # NOTE(TheJulia): If we deployed a whole disk image, we + # should expect a whole disk image and clean-up the tftp files + # on-disk incase the node is disregarding the boot preference. + # TODO(rameshg87): Not all in-tree drivers using reboot_to_instance + # have a boot interface. So include a check for now. Remove this + # check once all in-tree drivers have a boot interface. + if task.driver.boot: + task.driver.boot.clean_up_ramdisk(task) + + def reboot_and_finish_deploy(self, task): + """Helper method to trigger reboot on the node and finish deploy. + + This method initiates a reboot on the node. On success, it + marks the deploy as complete. On failure, it logs the error + and marks deploy as failure. + + :param task: a TaskManager object containing the node + :raises: InstanceDeployFailure, if node reboot failed. + """ + wait = CONF.agent.post_deploy_get_power_state_retry_interval * 1000 + attempts = CONF.agent.post_deploy_get_power_state_retries + 1 + + @retrying.retry( + stop_max_attempt_number=attempts, + retry_on_result=lambda state: state != states.POWER_OFF, + wait_fixed=wait + ) + def _wait_until_powered_off(task): + return task.driver.power.get_power_state(task) + + node = task.node + + try: + try: + self._client.power_off(node) + _wait_until_powered_off(task) + except Exception as e: + LOG.warning( + _LW('Failed to soft power off node %(node_uuid)s ' + 'in at least %(timeout)d seconds. Error: %(error)s'), + {'node_uuid': node.uuid, + 'timeout': (wait * (attempts - 1)) / 1000, + 'error': e}) + manager_utils.node_power_action(task, states.POWER_OFF) + + manager_utils.node_set_boot_device(task, 'disk', + persistent=True) + manager_utils.node_power_action(task, states.POWER_ON) + except Exception as e: + msg = (_('Error rebooting node %(node)s after deploy. ' + 'Error: %(error)s') % + {'node': node.uuid, 'error': e}) + self._log_and_raise_deployment_error(task, msg) + + task.process_event('done') + LOG.info(_LI('Deployment to node %s done'), task.node.uuid) diff --git a/ironic/drivers/oneview.py b/ironic/drivers/oneview.py new file mode 100644 index 0000000000..b437c99045 --- /dev/null +++ b/ironic/drivers/oneview.py @@ -0,0 +1,110 @@ +# +# Copyright 2015 Hewlett Packard Development Company, LP +# Copyright 2015 Universidade Federal de Campina Grande +# +# 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. + +""" +OneView Driver and supporting meta-classes. +""" +from oslo_utils import importutils + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.drivers import base +from ironic.drivers.modules import agent +from ironic.drivers.modules import fake +from ironic.drivers.modules import iscsi_deploy +from ironic.drivers.modules.oneview import common +from ironic.drivers.modules.oneview import management +from ironic.drivers.modules.oneview import power +from ironic.drivers.modules.oneview import vendor +from ironic.drivers.modules import pxe + + +class AgentPXEOneViewDriver(base.BaseDriver): + """Agent + OneView driver. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.ov.OVPower` for power on/off and reboot of virtual + machines, with :class:`ironic.driver.pxe.PXEBoot` for booting deploy kernel + and ramdisk and :class:`ironic.driver.iscsi_deploy.ISCSIDeploy` for image + deployment. Implementations are in those respective classes; this class is + merely the glue between them. + """ + + def __init__(self): + if not importutils.try_import('oneview_client.client'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason=_("Unable to import python-oneviewclient library")) + + # Checks connectivity to OneView and version compatibility on driver + # initialization + oneview_client = common.get_oneview_client() + oneview_client.verify_oneview_version() + oneview_client.verify_credentials() + self.power = power.OneViewPower() + self.management = management.OneViewManagement() + self.boot = pxe.PXEBoot() + self.deploy = agent.AgentDeploy() + self.vendor = vendor.AgentVendorInterface() + + +class ISCSIPXEOneViewDriver(base.BaseDriver): + """PXE + OneView driver. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.ov.OVPower` for power on/off and reboot of virtual + machines, with :class:`ironic.driver.pxe.PXEBoot` for booting deploy kernel + and ramdisk and :class:`ironic.driver.iscsi_deploy.ISCSIDeploy` for image + deployment. Implementations are in those respective classes; this class is + merely the glue between them. + """ + + def __init__(self): + if not importutils.try_import('oneview_client.client'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason=_("Unable to import python-oneviewclient library")) + + # Checks connectivity to OneView and version compatibility on driver + # initialization + oneview_client = common.get_oneview_client() + oneview_client.verify_oneview_version() + oneview_client.verify_credentials() + self.power = power.OneViewPower() + self.management = management.OneViewManagement() + self.boot = pxe.PXEBoot() + self.deploy = iscsi_deploy.ISCSIDeploy() + self.vendor = iscsi_deploy.VendorPassthru() + + +class FakeOneViewDriver(base.BaseDriver): + """Fake OneView driver. For testing purposes. """ + + def __init__(self): + if not importutils.try_import('oneview_client.client'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason=_("Unable to import python-oneviewclient library")) + + # Checks connectivity to OneView and version compatibility on driver + # initialization + oneview_client = common.get_oneview_client() + oneview_client.verify_oneview_version() + oneview_client.verify_credentials() + self.power = power.OneViewPower() + self.management = management.OneViewManagement() + self.boot = fake.FakeBoot() + self.deploy = fake.FakeDeploy() diff --git a/ironic/drivers/utils.py b/ironic/drivers/utils.py index 30329dd922..ef16b2aa32 100644 --- a/ironic/drivers/utils.py +++ b/ironic/drivers/utils.py @@ -13,6 +13,7 @@ # under the License. from oslo_log import log as logging +import six from ironic.common import exception from ironic.common.i18n import _ @@ -216,3 +217,28 @@ def force_persistent_boot(task, device, persistent): node.driver_internal_info = driver_internal_info node.save() + + +def capabilities_to_dict(capabilities): + """Parse the capabilities string into a dictionary + + :param capabilities: the capabilities of the node as a formatted string. + :raises: InvalidParameterValue if capabilities is not an string or has a + malformed value + """ + capabilities_dict = {} + if capabilities: + if not isinstance(capabilities, six.string_types): + raise exception.InvalidParameterValue( + _("Value of 'capabilities' must be string. Got %s") + % type(capabilities)) + try: + for capability in capabilities.split(','): + key, value = capability.split(':') + capabilities_dict[key] = value + except ValueError: + raise exception.InvalidParameterValue( + _("Malformed capabilities value: %s") % capability + ) + + return capabilities_dict diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index 7ea0c58112..d616add600 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -326,3 +326,21 @@ def get_test_cimc_info(): "cimc_password": "password", "cimc_address": "1.2.3.4", } + + +def get_test_oneview_properties(): + return { + "cpu_arch": "x86_64", + "cpus": "8", + "local_gb": "10", + "memory_mb": "4096", + "capabilities": "server_hardware_type_uri:fake_sht_uri," + "enclosure_group_uri:fake_eg_uri" + } + + +def get_test_oneview_driver_info(): + return { + 'server_hardware_uri': 'fake_uri', + 'server_profile_template_uri': 'fake_spt_uri' + } diff --git a/ironic/tests/unit/drivers/modules/oneview/__init__.py b/ironic/tests/unit/drivers/modules/oneview/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/drivers/modules/oneview/test_common.py b/ironic/tests/unit/drivers/modules/oneview/test_common.py new file mode 100644 index 0000000000..3cfb74da33 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/oneview/test_common.py @@ -0,0 +1,173 @@ +# -*- encoding: utf-8 -*- +# +# Copyright 2015 Hewlett Packard Development Company, LP +# Copyright 2015 Universidade Federal de Campina Grande +# +# 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. + +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.oneview import common +from ironic.tests.unit.conductor import utils as mgr_utils +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as obj_utils + +oneview_states = importutils.try_import('oneview_client.states') + +PROPERTIES_DICT = {"cpu_arch": "x86_64", + "cpus": "8", + "local_gb": "10", + "memory_mb": "4096", + "capabilities": "server_hardware_type_uri:fake_sht_uri," + "enclosure_group_uri:fake_eg_uri"} + +DRIVER_INFO_DICT = {'server_hardware_uri': 'fake_sh_uri', + 'server_profile_template_uri': 'fake_spt_uri'} + + +class OneViewCommonTestCase(db_base.DbTestCase): + + def setUp(self): + super(OneViewCommonTestCase, self).setUp() + self.node = obj_utils.create_test_node( + self.context, driver='fake_oneview', properties=PROPERTIES_DICT, + driver_info=DRIVER_INFO_DICT, + ) + self.config(manager_url='https://1.2.3.4', group='oneview') + self.config(username='user', group='oneview') + self.config(password='password', group='oneview') + mgr_utils.mock_the_extension_manager(driver="fake_oneview") + + def test_verify_node_info(self): + common.verify_node_info(self.node) + + def test_verify_node_info_missing_node_properties(self): + self.node.properties = { + "cpu_arch": "x86_64", + "cpus": "8", + "local_gb": "10", + "memory_mb": "4096", + "capabilities": "enclosure_group_uri:fake_eg_uri" + } + + exc = self.assertRaises( + exception.MissingParameterValue, + common.verify_node_info, + self.node + ) + self.assertEqual("Missing the keys for the following OneView data in " + "node's properties/capabilities: " + "server_hardware_type_uri.", + str(exc)) + + def test_verify_node_info_missing_node_driver_info(self): + self.node.driver_info = { + 'server_hardware_uri': 'fake_sh_uri' + } + + exc = self.assertRaises( + exception.MissingParameterValue, + common.verify_node_info, + self.node + ) + self.assertEqual("Missing the keys for the following OneView data in " + "node's driver_info: server_profile_template_uri.", + str(exc)) + + def test_get_oneview_info(self): + complete_node = self.node + expected_node_info = { + 'server_hardware_uri': 'fake_sh_uri', + 'server_hardware_type_uri': 'fake_sht_uri', + 'enclosure_group_uri': 'fake_eg_uri', + 'server_profile_template_uri': 'fake_spt_uri', + } + + self.assertEqual( + expected_node_info, + common.get_oneview_info(complete_node) + ) + + def test__verify_node_info(self): + common._verify_node_info("properties", + {"a": True, + "b": False, + "c": 0, + "d": "something", + "e": "somethingelse"}, + ["a", "b", "c", "e"]) + + def test__verify_node_info_fails(self): + self.assertRaises( + exception.MissingParameterValue, + common._verify_node_info, + "properties", + {"a": 1, "b": 2, "c": 3}, + ["x"] + ) + + def test__verify_node_info_missing_values_empty_string(self): + exc_expected_msg = ("Missing parameter value for: 'properties:a'" + ", 'properties:b'") + + self.assertRaisesRegexp( + exception.MissingParameterValue, + exc_expected_msg, + common._verify_node_info, + "properties", + {"a": '', "b": None, "c": "something"}, + ["a", "b", "c"] + ) + + def _test_translate_oneview_states(self, power_state_to_translate, + expected_translated_power_state): + translated_power_state = common.translate_oneview_power_state( + power_state_to_translate) + self.assertEqual(translated_power_state, + expected_translated_power_state) + + def test_all_scenarios_for_translate_oneview_states(self): + self._test_translate_oneview_states( + oneview_states.ONEVIEW_POWERING_OFF, states.POWER_ON) + self._test_translate_oneview_states( + oneview_states.ONEVIEW_POWER_OFF, states.POWER_OFF) + self._test_translate_oneview_states( + oneview_states.ONEVIEW_POWERING_ON, states.POWER_OFF) + self._test_translate_oneview_states( + oneview_states.ONEVIEW_RESETTING, states.REBOOT) + self._test_translate_oneview_states("anything", states.ERROR) + + @mock.patch.object(common, 'get_oneview_client', spec_set=True, + autospec=True) + def test_validate_oneview_resources_compatibility(self, + mock_get_ov_client): + oneview_client = mock_get_ov_client() + with task_manager.acquire(self.context, self.node.uuid) as task: + common.validate_oneview_resources_compatibility(task) + self.assertTrue( + oneview_client.validate_node_server_hardware.called) + self.assertTrue( + oneview_client.validate_node_server_hardware_type.called) + self.assertTrue( + oneview_client.check_server_profile_is_applied.called) + self.assertTrue( + oneview_client.is_node_port_mac_compatible_with_server_profile. + called) + self.assertTrue( + oneview_client.validate_node_enclosure_group.called) + self.assertTrue( + oneview_client.validate_node_server_profile_template.called) diff --git a/ironic/tests/unit/drivers/modules/oneview/test_management.py b/ironic/tests/unit/drivers/modules/oneview/test_management.py new file mode 100644 index 0000000000..b62b0c3125 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/oneview/test_management.py @@ -0,0 +1,184 @@ +# -*- encoding: utf-8 -*- +# +# Copyright 2015 Hewlett Packard Development Company, LP +# Copyright 2015 Universidade Federal de Campina Grande +# +# 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. + +import mock +from oslo_utils import importutils +from oslo_utils import uuidutils + +from ironic.common import boot_devices +from ironic.common import driver_factory +from ironic.common import exception +from ironic.conductor import task_manager +from ironic.drivers.modules.oneview import common +from ironic.drivers.modules.oneview import management +from ironic.tests.unit.conductor import utils as mgr_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 + + +oneview_exceptions = importutils.try_import('oneview_client.exceptions') + + +@mock.patch.object(common, 'get_oneview_client', spect_set=True, autospec=True) +class OneViewManagementDriverTestCase(db_base.DbTestCase): + + def setUp(self): + super(OneViewManagementDriverTestCase, self).setUp() + self.config(manager_url='https://1.2.3.4', group='oneview') + self.config(username='user', group='oneview') + self.config(password='password', group='oneview') + + mgr_utils.mock_the_extension_manager(driver="fake_oneview") + self.driver = driver_factory.get_driver("fake_oneview") + + self.node = obj_utils.create_test_node( + self.context, driver='fake_oneview', + properties=db_utils.get_test_oneview_properties(), + driver_info=db_utils.get_test_oneview_driver_info(), + ) + self.info = common.get_oneview_info(self.node) + + @mock.patch.object(common, 'validate_oneview_resources_compatibility', + spect_set=True, autospec=True) + def test_validate(self, mock_validate, mock_get_ov_client): + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.management.validate(task) + self.assertTrue(mock_validate.called) + + def test_validate_fail(self, mock_get_ov_client): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + id=999, + driver='fake_oneview') + with task_manager.acquire(self.context, node.uuid) as task: + self.assertRaises(exception.MissingParameterValue, + task.driver.management.validate, task) + + @mock.patch.object(common, 'validate_oneview_resources_compatibility', + spect_set=True, autospec=True) + def test_validate_fail_exception(self, mock_validate, mock_get_ov_client): + mock_validate.side_effect = exception.OneViewError('message') + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InvalidParameterValue, + task.driver.management.validate, + task) + + def test_get_properties(self, mock_get_ov_client): + expected = common.COMMON_PROPERTIES + self.assertItemsEqual(expected, + self.driver.management.get_properties()) + + def test_set_boot_device(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.management.set_boot_device(task, boot_devices.PXE) + oneview_client.set_boot_device.assert_called_once_with( + self.info, + management.BOOT_DEVICE_MAPPING_TO_OV.get(boot_devices.PXE) + ) + + def test_set_boot_device_invalid_device(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InvalidParameterValue, + self.driver.management.set_boot_device, + task, 'fake-device') + self.assertFalse(oneview_client.set_boot_device.called) + + def test_set_boot_device_fail_to_get_server_profile(self, + mock_get_ov_client): + oneview_client = mock_get_ov_client() + + oneview_client.get_server_profile_from_hardware.side_effect = \ + oneview_exceptions.OneViewException() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.OneViewError, + self.driver.management.set_boot_device, + task, 'disk') + self.assertFalse(oneview_client.set_boot_device.called) + + def test_set_boot_device_without_server_profile(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.get_server_profile_from_hardware.return_value = False + with task_manager.acquire(self.context, self.node.uuid) as task: + expected_msg = ( + 'A Server Profile is not associated with node %s.' + % self.node.uuid + ) + self.assertRaisesRegexp( + exception.OperationNotPermitted, + expected_msg, + self.driver.management.set_boot_device, + task, + 'disk' + ) + + def test_get_supported_boot_devices(self, mock_get_ov_client): + with task_manager.acquire(self.context, self.node.uuid) as task: + expected = [boot_devices.PXE, boot_devices.DISK, + boot_devices.CDROM] + self.assertItemsEqual( + expected, + task.driver.management.get_supported_boot_devices(task), + ) + + def test_get_boot_device(self, mock_get_ov_client): + device_mapping = management.BOOT_DEVICE_MAPPING_TO_OV + oneview_client = mock_get_ov_client() + + with task_manager.acquire(self.context, self.node.uuid) as task: + # For each known device on OneView, Ironic should return its + # counterpart value + for device_ironic, device_ov in device_mapping.items(): + oneview_client.get_boot_order.return_value = [device_ov] + expected_response = { + 'boot_device': device_ironic, + 'persistent': True + } + response = self.driver.management.get_boot_device(task) + self.assertEqual(expected_response, response) + oneview_client.get_boot_order.assert_called_with(self.info) + + def test_get_boot_device_fail(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.get_boot_order.side_effect = \ + oneview_exceptions.OneViewException() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.OneViewError, + self.driver.management.get_boot_device, + task) + oneview_client.get_boot_order.assert_called_with(self.info) + + def test_get_boot_device_unknown_device(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.get_boot_order.return_value = ["spam", + "bacon"] + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises( + exception.InvalidParameterValue, + task.driver.management.get_boot_device, + task + ) + + def test_get_sensors_data_not_implemented(self, mock_get_ov_client): + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises( + NotImplementedError, + task.driver.management.get_sensors_data, + task + ) diff --git a/ironic/tests/unit/drivers/modules/oneview/test_power.py b/ironic/tests/unit/drivers/modules/oneview/test_power.py new file mode 100644 index 0000000000..5311a601fc --- /dev/null +++ b/ironic/tests/unit/drivers/modules/oneview/test_power.py @@ -0,0 +1,192 @@ +# -*- encoding: utf-8 -*- +# +# Copyright 2015 Hewlett Packard Development Company, LP +# Copyright 2015 Universidade Federal de Campina Grande +# +# 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. + +import mock +from oslo_utils import importutils +from oslo_utils import uuidutils + +from ironic.common import driver_factory +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers.modules.oneview import common +from ironic.tests.unit.conductor import utils as mgr_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 + +oneview_exceptions = importutils.try_import('oneview_client.exceptions') + +# TODO(afaranha) move this variable to db_utils.get_test_oneview_properties() +PROPERTIES_DICT = {'cpu_arch': 'x86_64', + 'cpus': '8', + 'local_gb': '10', + 'memory_mb': '4096', + 'capabilities': 'server_hardware_type_uri:fake_sht_uri,' + 'enclosure_group_uri:fake_eg_uri', + } + +DRIVER_INFO_DICT = {'server_hardware_uri': 'fake_uri', + 'server_profile_template_uri': 'fake_spt_uri', + } + +POWER_ON = 'On' +POWER_OFF = 'Off' +ERROR = 'error' + + +@mock.patch.object(common, 'get_oneview_client', spec_set=True, autospec=True) +class OneViewPowerDriverTestCase(db_base.DbTestCase): + + def setUp(self): + super(OneViewPowerDriverTestCase, self).setUp() + self.config(manager_url='https://1.2.3.4', group='oneview') + self.config(username='user', group='oneview') + self.config(password='password', group='oneview') + + mgr_utils.mock_the_extension_manager(driver='fake_oneview') + self.driver = driver_factory.get_driver('fake_oneview') + + self.node = obj_utils.create_test_node( + self.context, driver='fake_oneview', + properties=db_utils.get_test_oneview_properties(), + driver_info=db_utils.get_test_oneview_driver_info(), + ) + self.info = common.get_oneview_info(self.node) + + @mock.patch.object(common, 'validate_oneview_resources_compatibility', + spect_set=True, autospec=True) + def test_power_interface_validate(self, mock_validate, mock_get_ov_client): + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.power.validate(task) + self.assertTrue(mock_validate.called) + + def test_power_interface_validate_fail(self, mock_get_ov_client): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + id=999, + driver='fake_oneview') + with task_manager.acquire(self.context, node.uuid) as task: + self.assertRaises(exception.MissingParameterValue, + task.driver.power.validate, task) + + @mock.patch.object(common, 'validate_oneview_resources_compatibility', + spect_set=True, autospec=True) + def test_power_interface_validate_fail_exception(self, mock_validate, + mock_get_ov_client): + mock_validate.side_effect = exception.OneViewError('message') + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InvalidParameterValue, + task.driver.power.validate, + task) + + def test_power_interface_get_properties(self, mock_get_ov_client): + expected = common.COMMON_PROPERTIES + self.assertItemsEqual(expected, self.driver.power.get_properties()) + + def test_get_power_state(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.get_node_power_state.return_value = POWER_ON + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.power.get_power_state(task) + oneview_client.get_node_power_state.assert_called_once_with(self.info) + + def test_get_power_state_fail(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.get_node_power_state.side_effect = \ + oneview_exceptions.OneViewException() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises( + exception.OneViewError, + self.driver.power.get_power_state, + task + ) + + def test_set_power_on(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.power_on.return_value = POWER_ON + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.power.set_power_state(task, states.POWER_ON) + oneview_client.power_on.assert_called_once_with(self.info) + + def test_set_power_off(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.power_off.return_value = POWER_OFF + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.power.set_power_state(task, states.POWER_OFF) + oneview_client.power_off.assert_called_once_with(self.info) + + def test_set_power_on_fail(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.power_on.side_effect = \ + oneview_exceptions.OneViewException() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.OneViewError, + self.driver.power.set_power_state, task, + states.POWER_ON) + oneview_client.power_on.assert_called_once_with(self.info) + + def test_set_power_off_fail(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.power_off.side_effect = \ + oneview_exceptions.OneViewException() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.OneViewError, + self.driver.power.set_power_state, task, + states.POWER_OFF) + oneview_client.power_off.assert_called_once_with(self.info) + + def test_set_power_invalid_state(self, mock_get_ov_client): + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InvalidParameterValue, + self.driver.power.set_power_state, task, + 'fake state') + + def test_set_power_reboot(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.power_off.return_value = POWER_OFF + oneview_client.power_on.return_value = POWER_ON + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.power.set_power_state(task, states.REBOOT) + oneview_client.power_off.assert_called_once_with(self.info) + oneview_client.power_on.assert_called_once_with(self.info) + + def test_reboot(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + + oneview_client.power_off.return_value = POWER_OFF + oneview_client.power_on.return_value = POWER_ON + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.power.reboot(task) + + oneview_client.power_off.assert_called_once_with(self.info) + oneview_client.power_on.assert_called_once_with(self.info) + + def test_reboot_fail(self, mock_get_ov_client): + oneview_client = mock_get_ov_client() + oneview_client.power_off.side_effect = \ + oneview_exceptions.OneViewException() + + with task_manager.acquire(self.context, + self.node.uuid) as task: + self.assertRaises(exception.OneViewError, + self.driver.power.reboot, + task) + + oneview_client.power_off.assert_called_once_with(self.info) + self.assertFalse(oneview_client.power_on.called) diff --git a/ironic/tests/unit/drivers/modules/oneview/test_vendor.py b/ironic/tests/unit/drivers/modules/oneview/test_vendor.py new file mode 100644 index 0000000000..69c9c3929e --- /dev/null +++ b/ironic/tests/unit/drivers/modules/oneview/test_vendor.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2015 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. + +import time +import types + +import mock + +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.conductor import utils as manager_utils +from ironic.drivers.modules import agent_client +from ironic.drivers.modules.oneview import power +from ironic.drivers.modules.oneview import vendor +from ironic.drivers.modules import pxe +from ironic.tests.unit.conductor import utils as mgr_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 + + +GET_POWER_STATE_RETRIES = 5 + + +class TestBaseAgentVendor(db_base.DbTestCase): + + def setUp(self): + super(TestBaseAgentVendor, self).setUp() + self.config( + post_deploy_get_power_state_retries=GET_POWER_STATE_RETRIES, + group='agent') + mgr_utils.mock_the_extension_manager(driver="agent_pxe_oneview") + self.passthru = vendor.AgentVendorInterface() + + self.node = obj_utils.create_test_node( + self.context, driver='agent_pxe_oneview', + properties=db_utils.get_test_oneview_properties(), + driver_info=db_utils.get_test_oneview_driver_info(), + ) + + @mock.patch.object(time, 'sleep', lambda seconds: None) + @mock.patch.object(manager_utils, 'node_power_action', autospec=True) + @mock.patch.object(power.OneViewPower, 'get_power_state', + spec=types.FunctionType) + @mock.patch.object(agent_client.AgentClient, 'power_off', + spec=types.FunctionType) + @mock.patch('ironic.conductor.utils.node_set_boot_device', autospec=True) + def test_reboot_and_finish_deploy(self, set_bootdev_mock, power_off_mock, + get_power_state_mock, + node_power_action_mock): + self.node.provision_state = states.DEPLOYING + self.node.target_provision_state = states.ACTIVE + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + get_power_state_mock.side_effect = [states.POWER_ON, + states.POWER_OFF] + self.passthru.reboot_and_finish_deploy(task) + power_off_mock.assert_called_once_with(task.node) + self.assertEqual(2, get_power_state_mock.call_count) + set_bootdev_mock.assert_called_once_with(task, 'disk', + persistent=True) + node_power_action_mock.assert_called_once_with( + task, states.POWER_ON) + self.assertEqual(states.ACTIVE, task.node.provision_state) + self.assertEqual(states.NOSTATE, task.node.target_provision_state) + + @mock.patch.object(time, 'sleep', lambda seconds: None) + @mock.patch.object(manager_utils, 'node_power_action', autospec=True) + @mock.patch.object(power.OneViewPower, 'get_power_state', + spec=types.FunctionType) + @mock.patch.object(agent_client.AgentClient, 'power_off', + spec=types.FunctionType) + def test_reboot_and_finish_deploy_soft_poweroff_doesnt_complete( + self, power_off_mock, get_power_state_mock, + node_power_action_mock): + self.node.provision_state = states.DEPLOYING + self.node.target_provision_state = states.ACTIVE + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + get_power_state_mock.return_value = states.POWER_ON + self.passthru.reboot_and_finish_deploy(task) + power_off_mock.assert_called_once_with(task.node) + self.assertEqual(GET_POWER_STATE_RETRIES + 1, + get_power_state_mock.call_count) + node_power_action_mock.assert_has_calls([ + mock.call(task, states.POWER_OFF), + mock.call(task, states.POWER_ON) + ]) + self.assertEqual(states.ACTIVE, task.node.provision_state) + self.assertEqual(states.NOSTATE, task.node.target_provision_state) + + @mock.patch.object(manager_utils, 'node_power_action', autospec=True) + @mock.patch.object(agent_client.AgentClient, 'power_off', + spec=types.FunctionType) + def test_reboot_and_finish_deploy_soft_poweroff_fails( + self, power_off_mock, node_power_action_mock): + power_off_mock.side_effect = iter([RuntimeError("boom")]) + self.node.provision_state = states.DEPLOYING + self.node.target_provision_state = states.ACTIVE + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.passthru.reboot_and_finish_deploy(task) + power_off_mock.assert_called_once_with(task.node) + node_power_action_mock.assert_has_calls([ + mock.call(task, states.POWER_OFF), + mock.call(task, states.POWER_ON) + ]) + self.assertEqual(states.ACTIVE, task.node.provision_state) + self.assertEqual(states.NOSTATE, task.node.target_provision_state) + + @mock.patch.object(time, 'sleep', lambda seconds: None) + @mock.patch.object(manager_utils, 'node_power_action', autospec=True) + @mock.patch.object(power.OneViewPower, 'get_power_state', + spec=types.FunctionType) + @mock.patch.object(agent_client.AgentClient, 'power_off', + spec=types.FunctionType) + def test_reboot_and_finish_deploy_get_power_state_fails( + self, power_off_mock, get_power_state_mock, + node_power_action_mock): + self.node.provision_state = states.DEPLOYING + self.node.target_provision_state = states.ACTIVE + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + get_power_state_mock.side_effect = iter([RuntimeError("boom")]) + self.passthru.reboot_and_finish_deploy(task) + power_off_mock.assert_called_once_with(task.node) + self.assertEqual(GET_POWER_STATE_RETRIES + 1, + get_power_state_mock.call_count) + node_power_action_mock.assert_has_calls([ + mock.call(task, states.POWER_OFF), + mock.call(task, states.POWER_ON) + ]) + self.assertEqual(states.ACTIVE, task.node.provision_state) + self.assertEqual(states.NOSTATE, task.node.target_provision_state) + + @mock.patch.object(time, 'sleep', lambda seconds: None) + @mock.patch.object(manager_utils, 'node_power_action', autospec=True) + @mock.patch.object(power.OneViewPower, 'get_power_state', + spec=types.FunctionType) + @mock.patch.object(agent_client.AgentClient, 'power_off', + spec=types.FunctionType) + def test_reboot_and_finish_deploy_power_action_fails( + self, power_off_mock, get_power_state_mock, + node_power_action_mock): + self.node.provision_state = states.DEPLOYING + self.node.target_provision_state = states.ACTIVE + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + get_power_state_mock.return_value = states.POWER_ON + node_power_action_mock.side_effect = iter([RuntimeError("boom")]) + self.assertRaises(exception.InstanceDeployFailure, + self.passthru.reboot_and_finish_deploy, + task) + power_off_mock.assert_called_once_with(task.node) + self.assertEqual(GET_POWER_STATE_RETRIES + 1, + get_power_state_mock.call_count) + node_power_action_mock.assert_has_calls([ + mock.call(task, states.POWER_OFF), + mock.call(task, states.POWER_OFF)]) + self.assertEqual(states.DEPLOYFAIL, task.node.provision_state) + self.assertEqual(states.ACTIVE, task.node.target_provision_state) + + @mock.patch.object(manager_utils, 'node_power_action', autospec=True) + @mock.patch.object(power.OneViewPower, 'get_power_state', + spec=types.FunctionType) + @mock.patch.object(agent_client.AgentClient, 'power_off', + spec=types.FunctionType) + @mock.patch('ironic.drivers.modules.agent.AgentVendorInterface' + '.check_deploy_success', autospec=True) + @mock.patch.object(pxe.PXEBoot, 'clean_up_ramdisk', autospec=True) + def test_reboot_to_instance(self, clean_pxe_mock, check_deploy_mock, + power_off_mock, get_power_state_mock, + node_power_action_mock): + check_deploy_mock.return_value = None + + self.node.provision_state = states.DEPLOYWAIT + self.node.target_provision_state = states.ACTIVE + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + get_power_state_mock.return_value = states.POWER_OFF + task.node.driver_internal_info['is_whole_disk_image'] = True + + self.passthru.reboot_to_instance(task) + + clean_pxe_mock.assert_called_once_with(task.driver.boot, task) + check_deploy_mock.assert_called_once_with(mock.ANY, task.node) + power_off_mock.assert_called_once_with(task.node) + get_power_state_mock.assert_called_once_with(task) + node_power_action_mock.assert_called_once_with( + task, states.POWER_ON) + self.assertEqual(states.ACTIVE, task.node.provision_state) + self.assertEqual(states.NOSTATE, task.node.target_provision_state) + + @mock.patch.object(manager_utils, 'node_power_action', autospec=True) + @mock.patch.object(power.OneViewPower, 'get_power_state', + spec=types.FunctionType) + @mock.patch.object(agent_client.AgentClient, 'power_off', + spec=types.FunctionType) + @mock.patch('ironic.drivers.modules.agent.AgentVendorInterface' + '.check_deploy_success', autospec=True) + @mock.patch.object(pxe.PXEBoot, 'clean_up_ramdisk', autospec=True) + def test_reboot_to_instance_boot_none(self, clean_pxe_mock, + check_deploy_mock, + power_off_mock, + get_power_state_mock, + node_power_action_mock): + check_deploy_mock.return_value = None + + self.node.provision_state = states.DEPLOYWAIT + self.node.target_provision_state = states.ACTIVE + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + get_power_state_mock.return_value = states.POWER_OFF + task.node.driver_internal_info['is_whole_disk_image'] = True + task.driver.boot = None + + self.passthru.reboot_to_instance(task) + + self.assertFalse(clean_pxe_mock.called) + check_deploy_mock.assert_called_once_with(mock.ANY, task.node) + power_off_mock.assert_called_once_with(task.node) + get_power_state_mock.assert_called_once_with(task) + node_power_action_mock.assert_called_once_with( + task, states.POWER_ON) + self.assertEqual(states.ACTIVE, task.node.provision_state) + self.assertEqual(states.NOSTATE, task.node.target_provision_state) diff --git a/ironic/tests/unit/drivers/test_utils.py b/ironic/tests/unit/drivers/test_utils.py index af49e0e678..90e08f9c8a 100644 --- a/ironic/tests/unit/drivers/test_utils.py +++ b/ironic/tests/unit/drivers/test_utils.py @@ -163,3 +163,68 @@ class UtilsTestCase(db_base.DbTestCase): False, task.node.driver_internal_info.get('is_next_boot_persistent') ) + + def test_capabilities_to_dict(self): + capabilities_more_than_one_item = 'a:b,c:d' + capabilities_exactly_one_item = 'e:f' + + # Testing empty capabilities + self.assertEqual( + {}, + driver_utils.capabilities_to_dict('') + ) + self.assertEqual( + {'e': 'f'}, + driver_utils.capabilities_to_dict(capabilities_exactly_one_item) + ) + self.assertEqual( + {'a': 'b', 'c': 'd'}, + driver_utils.capabilities_to_dict(capabilities_more_than_one_item) + ) + + def test_capabilities_to_dict_with_only_key_or_value_fail(self): + capabilities_only_key_or_value = 'xpto' + exc = self.assertRaises( + exception.InvalidParameterValue, + driver_utils.capabilities_to_dict, + capabilities_only_key_or_value + ) + self.assertEqual('Malformed capabilities value: xpto', str(exc)) + + def test_capabilities_to_dict_with_invalid_character_fail(self): + for test_capabilities in ('xpto:a,', ',xpto:a'): + exc = self.assertRaises( + exception.InvalidParameterValue, + driver_utils.capabilities_to_dict, + test_capabilities + ) + self.assertEqual('Malformed capabilities value: ', str(exc)) + + def test_capabilities_to_dict_with_incorrect_format_fail(self): + for test_capabilities in (':xpto,', 'xpto:,', ':,'): + exc = self.assertRaises( + exception.InvalidParameterValue, + driver_utils.capabilities_to_dict, + test_capabilities + ) + self.assertEqual('Malformed capabilities value: ', str(exc)) + + def test_capabilities_not_string(self): + capabilities_already_dict = {'a': 'b'} + capabilities_something_else = 42 + + exc = self.assertRaises( + exception.InvalidParameterValue, + driver_utils.capabilities_to_dict, + capabilities_already_dict + ) + self.assertEqual("Value of 'capabilities' must be string. Got " + + str(dict), str(exc)) + + exc = self.assertRaises( + exception.InvalidParameterValue, + driver_utils.capabilities_to_dict, + capabilities_something_else + ) + self.assertEqual("Value of 'capabilities' must be string. Got " + + str(int), str(exc)) 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 94666d043a..d1454469ee 100644 --- a/ironic/tests/unit/drivers/third_party_driver_mock_specs.py +++ b/ironic/tests/unit/drivers/third_party_driver_mock_specs.py @@ -103,6 +103,24 @@ SCCICLIENT_IRMC_SCCI_SPEC = ( 'get_virtual_fd_set_params_cmd', ) +ONEVIEWCLIENT_SPEC = ( + 'client', + 'states', + 'exceptions', +) + +ONEVIEWCLIENT_CLIENT_CLS_SPEC = ( +) + +ONEVIEWCLIENT_STATES_SPEC = ( + 'ONEVIEW_POWER_OFF', + 'ONEVIEW_POWERING_OFF', + 'ONEVIEW_POWER_ON', + 'ONEVIEW_POWERING_ON', + 'ONEVIEW_RESETTING', + 'ONEVIEW_ERROR', +) + # seamicro SEAMICRO_SPEC = ( 'client', diff --git a/ironic/tests/unit/drivers/third_party_driver_mocks.py b/ironic/tests/unit/drivers/third_party_driver_mocks.py index e88959aab1..c275df1563 100644 --- a/ironic/tests/unit/drivers/third_party_driver_mocks.py +++ b/ironic/tests/unit/drivers/third_party_driver_mocks.py @@ -27,6 +27,7 @@ Current list of mocked libraries: - proliantutils - pysnmp - scciclient +- oneview_client """ import sys @@ -99,6 +100,30 @@ if not proliantutils: six.moves.reload_module(sys.modules['ironic.drivers.ilo']) +oneview_client = importutils.try_import('oneview_client') +if not oneview_client: + oneview_client = mock.MagicMock(spec_set=mock_specs.ONEVIEWCLIENT_SPEC) + sys.modules['oneview_client'] = oneview_client + sys.modules['oneview_client.client'] = oneview_client.client + sys.modules['oneview_client.client.Client'] = mock.MagicMock( + spec_set=mock_specs.ONEVIEWCLIENT_CLIENT_CLS_SPEC + ) + states = mock.MagicMock( + spec_set=mock_specs.ONEVIEWCLIENT_STATES_SPEC, + ONEVIEW_POWER_OFF='Off', + ONEVIEW_POWERING_OFF='PoweringOff', + ONEVIEW_POWER_ON='On', + ONEVIEW_POWERING_ON='PoweringOn', + ONEVIEW_RESETTING='Resetting', + ONEVIEW_ERROR='error') + sys.modules['oneview_client.states'] = states + sys.modules['oneview_client.exceptions'] = oneview_client.exceptions + oneview_client.exceptions.OneViewException = type('OneViewException', + (Exception,), {}) + if 'ironic.drivers.oneview' in sys.modules: + six.moves.reload_module(sys.modules['ironic.drivers.modules.oneview']) + + # attempt to load the external 'pywsman' library, which is required by # the optional drivers.modules.drac and drivers.modules.amt module pywsman = importutils.try_import('pywsman') diff --git a/setup.cfg b/setup.cfg index 45c08aac1d..8e13b30bd3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,6 +35,7 @@ ironic.drivers = agent_ilo = ironic.drivers.ilo:IloVirtualMediaAgentDriver agent_ipmitool = ironic.drivers.agent:AgentAndIPMIToolDriver agent_irmc = ironic.drivers.irmc:IRMCVirtualMediaAgentDriver + agent_pxe_oneview = ironic.drivers.oneview:AgentPXEOneViewDriver agent_pyghmi = ironic.drivers.agent:AgentAndIPMINativeDriver agent_ssh = ironic.drivers.agent:AgentAndSSHDriver agent_vbox = ironic.drivers.agent:AgentAndVirtualBoxDriver @@ -58,8 +59,10 @@ ironic.drivers = fake_ucs = ironic.drivers.fake:FakeUcsDriver fake_cimc = ironic.drivers.fake:FakeCIMCDriver fake_wol = ironic.drivers.fake:FakeWakeOnLanDriver + fake_oneview = ironic.drivers.oneview:FakeOneViewDriver iscsi_ilo = ironic.drivers.ilo:IloVirtualMediaIscsiDriver iscsi_irmc = ironic.drivers.irmc:IRMCVirtualMediaIscsiDriver + iscsi_pxe_oneview = ironic.drivers.oneview:ISCSIPXEOneViewDriver pxe_ipmitool = ironic.drivers.pxe:PXEAndIPMIToolDriver pxe_ipminative = ironic.drivers.pxe:PXEAndIPMINativeDriver pxe_ssh = ironic.drivers.pxe:PXEAndSSHDriver