From a2d3e4c49370e1d4a48e1045c1e23d46bce87b33 Mon Sep 17 00:00:00 2001 From: Imre Farkas Date: Tue, 10 Jun 2014 12:28:29 +0200 Subject: [PATCH] Add DracDriver and its DracPower module Implements: blueprint drac-power-driver Change-Id: If93231c39ce901224f3a920f5342c44ba1b26851 --- doc/source/deploy/drivers.rst | 14 ++ doc/source/index.rst | 1 + ironic/common/exception.py | 11 ++ ironic/drivers/drac.py | 39 ++++ ironic/drivers/fake.py | 14 ++ ironic/drivers/modules/drac/__init__.py | 0 ironic/drivers/modules/drac/client.py | 80 +++++++++ ironic/drivers/modules/drac/common.py | 100 +++++++++++ ironic/drivers/modules/drac/power.py | 163 +++++++++++++++++ ironic/drivers/modules/drac/resource_uris.py | 20 +++ ironic/tests/db/utils.py | 11 ++ ironic/tests/drivers/drac/__init__.py | 0 ironic/tests/drivers/drac/test_client.py | 78 ++++++++ ironic/tests/drivers/drac/test_common.py | 104 +++++++++++ ironic/tests/drivers/drac/test_power.py | 170 ++++++++++++++++++ .../tests/drivers/third_party_driver_mocks.py | 13 ++ setup.cfg | 2 + 17 files changed, 820 insertions(+) create mode 100644 doc/source/deploy/drivers.rst create mode 100644 ironic/drivers/drac.py create mode 100644 ironic/drivers/modules/drac/__init__.py create mode 100644 ironic/drivers/modules/drac/client.py create mode 100644 ironic/drivers/modules/drac/common.py create mode 100644 ironic/drivers/modules/drac/power.py create mode 100644 ironic/drivers/modules/drac/resource_uris.py create mode 100644 ironic/tests/drivers/drac/__init__.py create mode 100644 ironic/tests/drivers/drac/test_client.py create mode 100644 ironic/tests/drivers/drac/test_common.py create mode 100644 ironic/tests/drivers/drac/test_power.py diff --git a/doc/source/deploy/drivers.rst b/doc/source/deploy/drivers.rst new file mode 100644 index 0000000000..1ecf49b798 --- /dev/null +++ b/doc/source/deploy/drivers.rst @@ -0,0 +1,14 @@ +.. _drivers: + +================= +Enabling Drivers +================= + +DRAC +---- + +DRAC with PXE deploy +^^^^^^^^^^^^^^^^^^^^ + +- Add ``pxe_drac`` to the list of ``enabled_drivers in`` ``/etc/ironic/ironic.conf`` +- Install openwsman-python package \ No newline at end of file diff --git a/doc/source/index.rst b/doc/source/index.rst index b8feff4dc3..b2ab02a060 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -62,6 +62,7 @@ Overview deploy/user-guide deploy/install-guide + deploy/drivers Indices and tables ================== diff --git a/ironic/common/exception.py b/ironic/common/exception.py index fbdc14d394..124ce1383d 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -408,6 +408,17 @@ class IloOperationError(IronicException): message = _("%(operation)s failed, error: %(error)s") +class DracClientError(IronicException): + message = _('DRAC client failed. ' + 'Last error (cURL error code): %(last_error)s, ' + 'fault string: "%(fault_string)s" ' + 'response_code: %(response_code)s') + + +class DracOperationError(IronicException): + message = _('DRAC %(operation)s failed. Reason: %(error)s') + + class FailedToGetSensorData(IronicException): message = _("Failed to get sensor data for node %(node)s. " "Error: %(error)s") diff --git a/ironic/drivers/drac.py b/ironic/drivers/drac.py new file mode 100644 index 0000000000..869230dd4f --- /dev/null +++ b/ironic/drivers/drac.py @@ -0,0 +1,39 @@ +# +# 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. +""" +DRAC Driver for remote system management using Dell Remote Access Card. +""" + +from ironic.common import exception +from ironic.drivers import base +from ironic.drivers.modules.drac import power +from ironic.drivers.modules import ipmitool +from ironic.drivers.modules import pxe +from ironic.openstack.common import importutils + + +class PXEDracDriver(base.BaseDriver): + + """Drac driver using PXE for deploy and ipmitool for management.""" + + def __init__(self): + if not importutils.try_import('pywsman'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason=_('Unable to import pywsman library')) + + self.power = power.DracPower() + self.deploy = pxe.PXEDeploy() + # NOTE(ifarkas): using ipmitool is a temporary solution. It will be + # replaced by the DracManagement interface. + self.management = ipmitool.IPMIManagement() diff --git a/ironic/drivers/fake.py b/ironic/drivers/fake.py index e7e0399c3b..7d4e7cfd20 100644 --- a/ironic/drivers/fake.py +++ b/ironic/drivers/fake.py @@ -22,6 +22,7 @@ from oslo.utils import importutils from ironic.common import exception from ironic.drivers import base from ironic.drivers.modules import agent +from ironic.drivers.modules.drac import power as drac_power from ironic.drivers.modules import fake from ironic.drivers.modules import iboot from ironic.drivers.modules.ilo import power as ilo_power @@ -128,3 +129,16 @@ class FakeIloDriver(base.BaseDriver): reason=_("Unable to import proliantutils library")) self.power = ilo_power.IloPower() self.deploy = fake.FakeDeploy() + + +class FakeDracDriver(base.BaseDriver): + """Fake Drac driver.""" + + def __init__(self): + if not importutils.try_import('pywsman'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason=_('Unable to import pywsman library')) + + self.power = drac_power.DracPower() + self.deploy = fake.FakeDeploy() diff --git a/ironic/drivers/modules/drac/__init__.py b/ironic/drivers/modules/drac/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/drivers/modules/drac/client.py b/ironic/drivers/modules/drac/client.py new file mode 100644 index 0000000000..4b1ccafc19 --- /dev/null +++ b/ironic/drivers/modules/drac/client.py @@ -0,0 +1,80 @@ +# +# 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. + +""" +Wrapper for pywsman.Client +""" + +from ironic.common import exception +from ironic.openstack.common import importutils + +pywsman = importutils.try_import('pywsman') + + +class Client(object): + + def __init__(self, drac_host, drac_port, drac_path, drac_protocol, + drac_username, drac_password): + pywsman_client = pywsman.Client(drac_host, drac_port, drac_path, + drac_protocol, drac_username, + drac_password) + # TODO(ifarkas): Add support for CACerts + pywsman.wsman_transport_set_verify_peer(pywsman_client, False) + + self.client = pywsman_client + + def wsman_enumerate(self, resource_uri, options, filter=None): + """Enumerates a remote WS-Man class. + + :param resource_uri: URI of the resource. + :param options: client options. + :param filter: filter for enumeration. + :returns: array of xml responses received. + """ + options.set_flags(pywsman.FLAG_ENUMERATION_OPTIMIZATION) + options.set_max_elements(100) + + partial_responses = [] + doc = self.client.enumerate(options, filter, resource_uri) + root = self._get_root(doc) + partial_responses.append(root) + + while doc.context() is not None: + doc = self.client.pull(options, None, resource_uri, + str(doc.context())) + root = self._get_root(doc) + partial_responses.append(root) + + return partial_responses + + def wsman_invoke(self, resource_uri, options, method): + """Invokes a remote WS-Man method. + + :param resource_uri: URI of the resource. + :param options: client options. + :param method: name of the method to invoke. + :returns: xml response received. + """ + doc = self.client.invoke(options, resource_uri, method) + root = self._get_root(doc) + + return root + + def _get_root(self, doc): + if doc is None or doc.root() is None: + raise exception.DracClientError( + last_error=self.client.last_error(), + fault_string=self.client.fault_string(), + response_code=self.client.response_code()) + + return doc.root() diff --git a/ironic/drivers/modules/drac/common.py b/ironic/drivers/modules/drac/common.py new file mode 100644 index 0000000000..14321af1fd --- /dev/null +++ b/ironic/drivers/modules/drac/common.py @@ -0,0 +1,100 @@ +# +# 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. + +""" +Common functionalities shared between different DRAC modules. +""" + +from ironic.common import exception +from ironic.drivers.modules.drac import client as drac_client +from ironic.openstack.common import importutils + +pywsman = importutils.try_import('pywsman') + +REQUIRED_PROPERTIES = { + 'drac_host': _('IP address or hostname of the DRAC card. Required.'), + 'drac_username': _('username used for authentication. Required.'), + 'drac_password': _('password used for authentication. Required.') +} +OPTIONAL_PROPERTIES = { + 'drac_port': _('port used for WS-Man endpoint; default is 443. Optional.'), + 'drac_path': _('path used for WS-Man endpoint; default is "/wsman". ' + 'Optional.'), + 'drac_protocol': _('protocol used for WS-Man endpoint; one of http, https;' + ' default is "https". Optional.'), +} +COMMON_PROPERTIES = REQUIRED_PROPERTIES.copy() +COMMON_PROPERTIES.update(OPTIONAL_PROPERTIES) + + +def parse_driver_info(node): + """Parses the driver_info of the node, reads default values + and returns a dict containing the combination of both. + + :param node: an ironic node object. + :returns: a dict containing information from driver_info + and default values. + :raises: InvalidParameterValue if some mandatory information + is missing on the node or on invalid inputs. + """ + driver_info = node.driver_info + parsed_driver_info = {} + + error_msgs = [] + for param in REQUIRED_PROPERTIES: + try: + parsed_driver_info[param] = str(driver_info[param]) + except KeyError: + error_msgs.append(_("'%s' not supplied to DracDriver.") % param) + except UnicodeEncodeError: + error_msgs.append(_("'%s' contains non-ASCII symbol.") % param) + + parsed_driver_info['drac_port'] = driver_info.get('drac_port', 443) + + try: + parsed_driver_info['drac_path'] = str(driver_info.get('drac_path', + '/wsman')) + except UnicodeEncodeError: + error_msgs.append(_("'drac_path' contains non-ASCII symbol.")) + + try: + parsed_driver_info['drac_protocol'] = str( + driver_info.get('drac_protocol', 'https')) + except UnicodeEncodeError: + error_msgs.append(_("'drac_protocol' contains non-ASCII symbol.")) + + try: + parsed_driver_info['drac_port'] = int(parsed_driver_info['drac_port']) + except ValueError: + error_msgs.append(_("'drac_port' is not an integer value.")) + + if error_msgs: + msg = (_('The following errors were encountered while parsing ' + 'driver_info:\n%s') % '\n'.join(error_msgs)) + raise exception.InvalidParameterValue(msg) + + return parsed_driver_info + + +def get_wsman_client(node): + """Given an ironic node object, this method gives back a + Client object which is a wrapper for pywsman.Client. + + :param node: an ironic node object. + :returns: a Client object. + :raises: InvalidParameterValue if some mandatory information + is missing on the node or on invalid inputs. + """ + driver_info = parse_driver_info(node) + client = drac_client.Client(**driver_info) + return client diff --git a/ironic/drivers/modules/drac/power.py b/ironic/drivers/modules/drac/power.py new file mode 100644 index 0000000000..7cefff9b5d --- /dev/null +++ b/ironic/drivers/modules/drac/power.py @@ -0,0 +1,163 @@ +# +# 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. + +""" +DRAC Power Driver using the Base Server Profile +""" + +from ironic.common import exception +from ironic.common import i18n +from ironic.common import states +from ironic.drivers import base +from ironic.drivers.modules.drac import common as drac_common +from ironic.drivers.modules.drac import resource_uris +from ironic.openstack.common import excutils +from ironic.openstack.common import importutils +from ironic.openstack.common import log as logging + +pywsman = importutils.try_import('pywsman') + +LOG = logging.getLogger(__name__) + +_LE = i18n._LE + +POWER_STATES = { + '2': states.POWER_ON, + '3': states.POWER_OFF, + '11': states.REBOOT, +} + +REVERSE_POWER_STATES = dict((v, k) for (k, v) in POWER_STATES.items()) + + +def _get_power_state(node): + """Returns the current power state of the node + + :param node: The node. + :returns: power state, one of :mod: `ironic.common.states`. + :raises: DracClientError if the client received unexpected response. + :raises: InvalidParameterValue if required DRAC credentials are missing. + """ + + client = drac_common.get_wsman_client(node) + options = pywsman.ClientOptions() + filter = pywsman.Filter() + filter_dialect = 'http://schemas.dmtf.org/wbem/cql/1/dsp0202.pdf' + filter_query = ('select EnabledState,ElementName from CIM_ComputerSystem ' + 'where Name="srv:system"') + filter.simple(filter_dialect, filter_query) + + try: + docs = client.wsman_enumerate(resource_uris.DCIM_ComputerSystem, + options, filter) + except exception.DracClientError as exc: + with excutils.save_and_reraise_exception(): + LOG.error(_LE('DRAC driver failed to get power state for node ' + '%(node_uuid)s. Reason: %(error)s.'), + {'node_uuid': node.uuid, 'error': exc}) + + doc = docs[0] + enabled_state = str(doc.find(resource_uris.DCIM_ComputerSystem, + 'EnabledState')) + + return POWER_STATES[enabled_state] + + +def _set_power_state(node, target_state): + """Turns the server power on/off or do a reboot. + + :param node: an ironic node object. + :param target_state: target state of the node. + :raises: DracClientError if the client received unexpected response. + :raises: InvalidParameterValue if an invalid power state was specified. + """ + + client = drac_common.get_wsman_client(node) + options = pywsman.ClientOptions() + options.add_selector('CreationClassName', 'DCIM_ComputerSystem') + options.add_selector('Name', 'srv:system') + options.add_property('RequestedState', REVERSE_POWER_STATES[target_state]) + + try: + root = client.wsman_invoke(resource_uris.DCIM_ComputerSystem, options, + 'RequestStateChange') + except exception.DracClientError as exc: + with excutils.save_and_reraise_exception(): + LOG.error(_LE('DRAC driver failed to set power state for node ' + '%(node_uuid)s to %(target_power_state)s. ' + 'Reason: %(error)s.'), + {'node_uuid': node.uuid, + 'target_power_state': target_state, + 'error': exc}) + + return_value = str(root.find(resource_uris.DCIM_ComputerSystem, + 'ReturnValue')) + + if return_value != '0': + message = str(root.find(resource_uris.DCIM_ComputerSystem, 'Message')) + LOG.error(_LE('DRAC driver failed to set power state for node ' + '%(node_uuid)s to %(target_power_state)s. ' + 'Reason: %(error)s.'), + {'node_uuid': node.uuid, + 'target_power_state': target_state, + 'error': message}) + raise exception.DracOperationError(operation='set_power_state', + error=message) + + +class DracPower(base.PowerInterface): + """Interface for power-related actions.""" + + def get_properties(self): + return drac_common.COMMON_PROPERTIES + + def validate(self, task): + """Validate the driver-specific Node power info. + + This method validates whether the 'driver_info' property of the + supplied node contains the required information for this driver to + manage the power state of the node. + + :param task: a TaskManager instance containing the node to act on. + :raises: InvalidParameterValue if required driver_info attribute + is missing or invalid on the node. + """ + return drac_common.parse_driver_info(task.node) + + def get_power_state(self, task): + """Return the 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: DracClientError if the client received unexpected response. + """ + return _get_power_state(task.node) + + def set_power_state(self, task, power_state): + """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`. + :raises: DracClientError if the client received unexpected response. + :raises: DracOperationError if failed to set the power state. + """ + return _set_power_state(task.node, power_state) + + def reboot(self, task): + """Perform a hard reboot of the task's node. + + :param task: a TaskManager instance containing the node to act on. + :raises: DracClientError if the client received unexpected response. + :raises: DracOperationError if failed to set the power state. + """ + return _set_power_state(task.node, states.REBOOT) diff --git a/ironic/drivers/modules/drac/resource_uris.py b/ironic/drivers/modules/drac/resource_uris.py new file mode 100644 index 0000000000..69b1549c57 --- /dev/null +++ b/ironic/drivers/modules/drac/resource_uris.py @@ -0,0 +1,20 @@ +# +# 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. + +""" +Resource URIs and helper functions for the classes implemented by the DRAC +WS-Man API. +""" + +DCIM_ComputerSystem = ('http://schemas.dell.com/wbem/wscim/1/cim-schema/2' + '/DCIM_ComputerSystem') diff --git a/ironic/tests/db/utils.py b/ironic/tests/db/utils.py index 8ca47f6025..631e1085b9 100644 --- a/ironic/tests/db/utils.py +++ b/ironic/tests/db/utils.py @@ -78,6 +78,17 @@ def get_test_ilo_info(): } +def get_test_drac_info(): + return { + "drac_host": "1.2.3.4", + "drac_port": "443", + "drac_path": "/wsman", + "drac_protocol": "https", + "drac_username": "admin", + "drac_password": "fake", + } + + def get_test_agent_instance_info(): return { 'image_source': 'fake-image', diff --git a/ironic/tests/drivers/drac/__init__.py b/ironic/tests/drivers/drac/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/drivers/drac/test_client.py b/ironic/tests/drivers/drac/test_client.py new file mode 100644 index 0000000000..a619a2fc30 --- /dev/null +++ b/ironic/tests/drivers/drac/test_client.py @@ -0,0 +1,78 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Test class for DRAC client wrapper. +""" + +import mock + +from ironic.drivers.modules.drac import client as drac_client +from ironic.tests import base +from ironic.tests.db import utils as db_utils + +INFO_DICT = db_utils.get_test_drac_info() + + +@mock.patch.object(drac_client, 'pywsman') +class DracClientTestCase(base.TestCase): + + def test_wsman_enumerate(self, mock_client_pywsman): + mock_xml = mock.Mock() + mock_xml.context.return_value = None + + mock_pywsman_client = mock_client_pywsman.Client.return_value + mock_pywsman_client.enumerate.return_value = mock_xml + + resource_uri = 'https://foo/wsman' + mock_options = mock_client_pywsman.ClientOptions.return_value + client = drac_client.Client(**INFO_DICT) + client.wsman_enumerate(resource_uri, mock_options) + + mock_options.set_flags.assert_called_once_with( + mock_client_pywsman.FLAG_ENUMERATION_OPTIMIZATION) + mock_options.set_max_elements.assert_called_once_with(100) + mock_pywsman_client.enumerate.assert_called_once_with(mock_options, + None, resource_uri) + mock_xml.context.assert_called_once_with() + + def test_wsman_enumerate_with_additional_pull(self, mock_client_pywsman): + mock_xml = mock.Mock() + mock_xml.context.side_effect = [42, 42, None] + + mock_pywsman_client = mock_client_pywsman.Client.return_value + mock_pywsman_client.enumerate.return_value = mock_xml + mock_pywsman_client.pull.return_value = mock_xml + + resource_uri = 'https://foo/wsman' + mock_options = mock_client_pywsman.ClientOptions.return_value + client = drac_client.Client(**INFO_DICT) + client.wsman_enumerate(resource_uri, mock_options) + + mock_options.set_flags.assert_called_once_with( + mock_client_pywsman.FLAG_ENUMERATION_OPTIMIZATION) + mock_options.set_max_elements.assert_called_once_with(100) + mock_pywsman_client.enumerate.assert_called_once_with(mock_options, + None, resource_uri) + + def test_wsman_invoke(self, mock_client_pywsman): + mock_pywsman_client = mock_client_pywsman.Client.return_value + + resource_uri = 'https://foo/wsman' + mock_options = mock_client_pywsman.ClientOptions.return_value + method_name = 'method' + client = drac_client.Client(**INFO_DICT) + client.wsman_invoke(resource_uri, mock_options, method_name) + + mock_pywsman_client.invoke.assert_called_once_with(mock_options, + resource_uri, method_name) diff --git a/ironic/tests/drivers/drac/test_common.py b/ironic/tests/drivers/drac/test_common.py new file mode 100644 index 0000000000..49eb19ee7b --- /dev/null +++ b/ironic/tests/drivers/drac/test_common.py @@ -0,0 +1,104 @@ +# +# 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 common methods used by DRAC modules. +""" + +from ironic.common import exception +from ironic.drivers.modules.drac import common as drac_common +from ironic.openstack.common import context +from ironic.tests import base +from ironic.tests.db import utils as db_utils +from ironic.tests.objects import utils as obj_utils + +INFO_DICT = db_utils.get_test_drac_info() + + +class DracCommonMethodsTestCase(base.TestCase): + + def setUp(self): + super(DracCommonMethodsTestCase, self).setUp() + self.context = context.get_admin_context() + + def test_parse_driver_info(self): + node = obj_utils.create_test_node(self.context, + driver='fake_drac', + driver_info=INFO_DICT) + info = drac_common.parse_driver_info(node) + + self.assertIsNotNone(info.get('drac_host')) + self.assertIsNotNone(info.get('drac_port')) + self.assertIsNotNone(info.get('drac_path')) + self.assertIsNotNone(info.get('drac_protocol')) + self.assertIsNotNone(info.get('drac_username')) + self.assertIsNotNone(info.get('drac_password')) + + def test_parse_driver_info_missing_host(self): + node = obj_utils.create_test_node(self.context, + driver='fake_drac', + driver_info=INFO_DICT) + del node.driver_info['drac_host'] + self.assertRaises(exception.InvalidParameterValue, + drac_common.parse_driver_info, node) + + def test_parse_driver_info_missing_port(self): + node = obj_utils.create_test_node(self.context, + driver='fake_drac', + driver_info=INFO_DICT) + del node.driver_info['drac_port'] + + info = drac_common.parse_driver_info(node) + self.assertEqual(443, info.get('drac_port')) + + def test_parse_driver_info_invalid_port(self): + node = obj_utils.create_test_node(self.context, + driver='fake_drac', + driver_info=INFO_DICT) + node.driver_info['drac_port'] = 'foo' + self.assertRaises(exception.InvalidParameterValue, + drac_common.parse_driver_info, node) + + def test_parse_driver_info_missing_path(self): + node = obj_utils.create_test_node(self.context, + driver='fake_drac', + driver_info=INFO_DICT) + del node.driver_info['drac_path'] + + info = drac_common.parse_driver_info(node) + self.assertEqual('/wsman', info.get('drac_path')) + + def test_parse_driver_info_missing_protocol(self): + node = obj_utils.create_test_node(self.context, + driver='fake_drac', + driver_info=INFO_DICT) + del node.driver_info['drac_protocol'] + + info = drac_common.parse_driver_info(node) + self.assertEqual('https', info.get('drac_protocol')) + + def test_parse_driver_info_missing_username(self): + node = obj_utils.create_test_node(self.context, + driver='fake_drac', + driver_info=INFO_DICT) + del node.driver_info['drac_username'] + self.assertRaises(exception.InvalidParameterValue, + drac_common.parse_driver_info, node) + + def test_parse_driver_info_missing_password(self): + node = obj_utils.create_test_node(self.context, + driver='fake_drac', + driver_info=INFO_DICT) + del node.driver_info['drac_password'] + self.assertRaises(exception.InvalidParameterValue, + drac_common.parse_driver_info, node) diff --git a/ironic/tests/drivers/drac/test_power.py b/ironic/tests/drivers/drac/test_power.py new file mode 100644 index 0000000000..a86542d545 --- /dev/null +++ b/ironic/tests/drivers/drac/test_power.py @@ -0,0 +1,170 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Test class for DRAC Power Driver +""" + +import mock + +from ironic.common import exception +from ironic.common import states +from ironic.db import api as dbapi +from ironic.drivers.modules.drac import client as drac_client +from ironic.drivers.modules.drac import common as drac_common +from ironic.drivers.modules.drac import power as drac_power +from ironic.drivers.modules.drac import resource_uris +from ironic.openstack.common import context +from ironic.tests import base +from ironic.tests.db import utils as db_utils + +INFO_DICT = db_utils.get_test_drac_info() + + +@mock.patch.object(drac_client, 'pywsman') +@mock.patch.object(drac_power, 'pywsman') +class DracPowerInternalMethodsTestCase(base.TestCase): + + def setUp(self): + super(DracPowerInternalMethodsTestCase, self).setUp() + driver_info = INFO_DICT + db_node = db_utils.get_test_node(driver='fake_drac', + driver_info=driver_info, + instance_uuid='instance_uuid_123') + self.dbapi = dbapi.get_instance() + self.node = self.dbapi.create_node(db_node) + + def test__get_power_state(self, mock_power_pywsman, mock_client_pywsman): + mock_xml_root = mock.Mock() + mock_xml_root.find.return_value = '2' + + mock_xml = mock.Mock() + mock_xml.context.return_value = None + mock_xml.root.return_value = mock_xml_root + + mock_pywsman_client = mock_client_pywsman.Client.return_value + mock_pywsman_client.enumerate.return_value = mock_xml + + self.assertEqual(states.POWER_ON, + drac_power._get_power_state(self.node)) + + mock_pywsman_client.enumerate.assert_called_once_with(mock.ANY, + mock.ANY, resource_uris.DCIM_ComputerSystem) + mock_xml_root.find.assert_called_once_with( + resource_uris.DCIM_ComputerSystem, 'EnabledState') + + def test__set_power_state(self, mock_power_pywsman, mock_client_pywsman): + mock_xml_root = mock.Mock() + mock_xml_root.find.return_value = '0' + + mock_xml = mock.Mock() + mock_xml.root.return_value = mock_xml_root + + mock_pywsman_client = mock_client_pywsman.Client.return_value + mock_pywsman_client.invoke.return_value = mock_xml + + mock_pywsman_clientopts = mock_power_pywsman.ClientOptions.return_value + + drac_power._set_power_state(self.node, states.POWER_ON) + + mock_pywsman_clientopts.add_selector.assert_has_calls([ + mock.call('CreationClassName', 'DCIM_ComputerSystem'), + mock.call('Name', 'srv:system') + ]) + mock_pywsman_clientopts.add_property.assert_called_once_with( + 'RequestedState', '2') + + mock_pywsman_client.invoke.assert_called_once_with(mock.ANY, + resource_uris.DCIM_ComputerSystem, 'RequestStateChange') + mock_xml_root.find.assert_called_once_with( + resource_uris.DCIM_ComputerSystem, 'ReturnValue') + + def test__set_power_state_fail(self, mock_power_pywsman, + mock_client_pywsman): + mock_xml_root = mock.Mock() + mock_xml_root.find.side_effect = ['1', 'error message'] + + mock_xml = mock.Mock() + mock_xml.root.return_value = mock_xml_root + + mock_pywsman_client = mock_client_pywsman.Client.return_value + mock_pywsman_client.invoke.return_value = mock_xml + + mock_pywsman_clientopts = mock_power_pywsman.ClientOptions.return_value + + self.assertRaises(exception.DracOperationError, + drac_power._set_power_state, self.node, + states.POWER_ON) + + mock_pywsman_clientopts.add_selector.assert_has_calls([ + mock.call('CreationClassName', 'DCIM_ComputerSystem'), + mock.call('Name', 'srv:system') + ]) + mock_pywsman_clientopts.add_property.assert_called_once_with( + 'RequestedState', '2') + + mock_pywsman_client.invoke.assert_called_once_with(mock.ANY, + resource_uris.DCIM_ComputerSystem, 'RequestStateChange') + + mock_xml_root.find.assert_has_calls([ + mock.call(resource_uris.DCIM_ComputerSystem, 'ReturnValue'), + mock.call(resource_uris.DCIM_ComputerSystem, 'Message') + ]) + + +class DracPowerTestCase(base.TestCase): + + def setUp(self): + super(DracPowerTestCase, self).setUp() + driver_info = INFO_DICT + db_node = db_utils.get_test_node(driver='fake_drac', + driver_info=driver_info, + instance_uuid='instance_uuid_123') + self.dbapi = dbapi.get_instance() + self.node = self.dbapi.create_node(db_node) + self.context = context.get_admin_context() + + def test_get_properties(self): + expected = drac_common.COMMON_PROPERTIES + driver = drac_power.DracPower() + self.assertEqual(expected, driver.get_properties()) + + @mock.patch.object(drac_power, '_get_power_state') + def test_get_power_state(self, mock_get_power_state): + mock_get_power_state.return_value = states.POWER_ON + driver = drac_power.DracPower() + task = mock.Mock() + task.node.return_value = self.node + + self.assertEqual(states.POWER_ON, driver.get_power_state(task)) + mock_get_power_state.assert_called_once_with(task.node) + + @mock.patch.object(drac_power, '_set_power_state') + def test_set_power_state(self, mock_set_power_state): + driver = drac_power.DracPower() + task = mock.Mock() + task.node.return_value = self.node + + driver.set_power_state(task, states.POWER_ON) + mock_set_power_state.assert_called_once_with(task.node, + states.POWER_ON) + + @mock.patch.object(drac_power, '_set_power_state') + def test_reboot(self, mock_set_power_state): + driver = drac_power.DracPower() + task = mock.Mock() + task.node.return_value = self.node + + driver.reboot(task) + mock_set_power_state.assert_called_once_with(task.node, + states.REBOOT) diff --git a/ironic/tests/drivers/third_party_driver_mocks.py b/ironic/tests/drivers/third_party_driver_mocks.py index f9877f06a3..3cd03a1cff 100644 --- a/ironic/tests/drivers/third_party_driver_mocks.py +++ b/ironic/tests/drivers/third_party_driver_mocks.py @@ -85,6 +85,19 @@ if 'ironic.drivers.ilo' in sys.modules: reload(sys.modules['ironic.drivers.ilo']) +# attempt to load the external 'pywsman' library, which is required by +# the optional drivers.modules.drac module +pywsman = importutils.try_import('pywsman') +if not pywsman: + pywsman = mock.Mock() + sys.modules['pywsman'] = pywsman + +# if anything has loaded the drac driver yet, reload it now that the +# external library has been mocked +if 'ironic.drivers.modules.drac' in sys.modules: + reload(sys.modules['ironic.drivers.modules.drac']) + + # attempt to load the external 'iboot' library, which is required by # the optional drivers.modules.iboot module iboot = importutils.try_import("iboot") diff --git a/setup.cfg b/setup.cfg index e8915ec02e..fd1df2586a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,12 +42,14 @@ ironic.drivers = fake_seamicro = ironic.drivers.fake:FakeSeaMicroDriver fake_iboot = ironic.drivers.fake:FakeIBootDriver fake_ilo = ironic.drivers.fake:FakeIloDriver + fake_drac = ironic.drivers.fake:FakeDracDriver pxe_ipmitool = ironic.drivers.pxe:PXEAndIPMIToolDriver pxe_ipminative = ironic.drivers.pxe:PXEAndIPMINativeDriver pxe_ssh = ironic.drivers.pxe:PXEAndSSHDriver pxe_seamicro = ironic.drivers.pxe:PXEAndSeaMicroDriver pxe_iboot = ironic.drivers.pxe:PXEAndIBootDriver pxe_ilo = ironic.drivers.pxe:PXEAndIloDriver + pxe_drac = ironic.drivers.drac:PXEDracDriver [pbr] autodoc_index_modules = True