From f72a694784f2c3d96ef8aa59433885c3354335a3 Mon Sep 17 00:00:00 2001 From: Lin Tan Date: Fri, 6 Feb 2015 14:31:19 +0800 Subject: [PATCH] Add AMT-PXE-Driver Power&Management&Vendor Interface Introduce a new driver pxe-amt to extend Ironic's range to desktops. AMT (Active Management Technology)/vPro is widely used in desktops to remotely control the power, similar to IPMI in servers. It will use AMT as power management and PXE as deploy management. This patch only provides basic operations to support the workflow of remotely deploying on AMT/vPro system. This adds power, management and it's own vendor interface. This also adds a new fake-amt driver. Implements blueprint amt-pxe-driver Change-Id: Idd9b63d124f52e24efab8b49dfe1f2e25b8387e6 --- driver-requirements.txt | 2 +- etc/ironic/ironic.conf.sample | 13 + ironic/drivers/fake.py | 15 + ironic/drivers/modules/amt/management.py | 196 +++++++++++++ ironic/drivers/modules/amt/power.py | 266 ++++++++++++++++++ ironic/drivers/modules/amt/vendor.py | 30 ++ ironic/drivers/pxe.py | 23 ++ ironic/tests/drivers/amt/test_management.py | 223 +++++++++++++++ ironic/tests/drivers/amt/test_power.py | 260 +++++++++++++++++ ironic/tests/drivers/amt/test_vendor.py | 65 +++++ .../tests/drivers/third_party_driver_mocks.py | 13 +- setup.cfg | 2 + 12 files changed, 1101 insertions(+), 7 deletions(-) create mode 100644 ironic/drivers/modules/amt/management.py create mode 100644 ironic/drivers/modules/amt/power.py create mode 100644 ironic/drivers/modules/amt/vendor.py create mode 100644 ironic/tests/drivers/amt/test_management.py create mode 100644 ironic/tests/drivers/amt/test_power.py create mode 100644 ironic/tests/drivers/amt/test_vendor.py diff --git a/driver-requirements.txt b/driver-requirements.txt index a840e306d2..da39fd2a09 100644 --- a/driver-requirements.txt +++ b/driver-requirements.txt @@ -11,7 +11,7 @@ pysnmp python-scciclient python-seamicroclient -# The drac driver imports a python module called "pywsman", however, +# The drac and amt driver import a python module called "pywsman", however, # this does not exist on pypi. # It is installed by the openwsman-python (on RH) or python-openwsman (on deb) # package, from https://github.com/Openwsman/openwsman/blob/master/bindings/python/Makefile.am#L29 diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index 87a2335af8..4af89adb20 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -508,6 +508,19 @@ #protocol=http +# +# Options defined in ironic.drivers.modules.amt.power +# + +# Maximum number of times to attempt an AMT operation, before +# failing (integer value) +#max_attempts=3 + +# Amount of time (in seconds) to wait, before retrying an AMT +# operation (integer value) +#action_wait=10 + + [api] # diff --git a/ironic/drivers/fake.py b/ironic/drivers/fake.py index 31228d2914..d949f46409 100644 --- a/ironic/drivers/fake.py +++ b/ironic/drivers/fake.py @@ -23,6 +23,8 @@ 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.amt import management as amt_mgmt +from ironic.drivers.modules.amt import power as amt_power from ironic.drivers.modules import discoverd from ironic.drivers.modules.drac import management as drac_mgmt from ironic.drivers.modules.drac import power as drac_power @@ -214,3 +216,16 @@ class FakeIPMIToolDiscoverdDriver(base.BaseDriver): self.vendor = ipmitool.VendorPassthru() self.management = ipmitool.IPMIManagement() self.inspect = discoverd.DiscoverdInspect() + + +class FakeAMTDriver(base.BaseDriver): + """Fake AMT 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 = amt_power.AMTPower() + self.deploy = fake.FakeDeploy() + self.management = amt_mgmt.AMTManagement() diff --git a/ironic/drivers/modules/amt/management.py b/ironic/drivers/modules/amt/management.py new file mode 100644 index 0000000000..f050a50545 --- /dev/null +++ b/ironic/drivers/modules/amt/management.py @@ -0,0 +1,196 @@ +# +# 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. + +""" +AMT Management Driver +""" +import copy + +from oslo_utils import excutils +from oslo_utils import importutils + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common.i18n import _LE +from ironic.common.i18n import _LI +from ironic.conductor import task_manager +from ironic.drivers import base +from ironic.drivers.modules.amt import common as amt_common +from ironic.drivers.modules.amt import resource_uris +from ironic.openstack.common import log as logging + +pywsman = importutils.try_import('pywsman') + +LOG = logging.getLogger(__name__) + + +def _set_boot_device_order(node, boot_device): + """Set boot device order configuration of AMT Client. + + :param node: a node object + :param boot_device: the boot device + :raises: AMTFailure + :raises: AMTConnectFailure + """ + client = amt_common.get_wsman_client(node) + source = pywsman.EndPointReference(resource_uris.CIM_BootSourceSetting, + None) + device = amt_common.BOOT_DEVICES_MAPPING[boot_device] + source.add_selector('InstanceID', device) + + method = 'ChangeBootOrder' + + options = pywsman.ClientOptions() + options.add_selector('InstanceID', 'Intel(r) AMT: Boot Configuration 0') + + options.add_property('Source', source) + try: + client.wsman_invoke(options, resource_uris.CIM_BootConfigSetting, + method) + except (exception.AMTFailure, exception.AMTConnectFailure) as e: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE("Failed to set boot device %(boot_device)s for " + "node %(node_id)s with error: %(error)s."), + {'boot_device': boot_device, 'node_id': node.uuid, + 'error': e}) + else: + LOG.info(_LI("Successfully set boot device %(boot_device)s for " + "node %(node_id)s"), + {'boot_device': boot_device, 'node_id': node.uuid}) + + +def _enable_boot_config(node): + """Enable boot configuration of AMT Client. + + :param node: a node object + :raises: AMTFailure + :raises: AMTConnectFailure + """ + client = amt_common.get_wsman_client(node) + config = pywsman.EndPointReference(resource_uris.CIM_BootConfigSetting, + None) + config.add_selector('InstanceID', 'Intel(r) AMT: Boot Configuration 0') + + method = 'SetBootConfigRole' + + options = pywsman.ClientOptions() + options.add_selector('Name', 'Intel(r) AMT Boot Service') + + options.add_property('Role', '1') + options.add_property('BootConfigSetting', config) + try: + client.wsman_invoke(options, resource_uris.CIM_BootService, method) + except (exception.AMTFailure, exception.AMTConnectFailure) as e: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE("Failed to enable boot config for node " + "%(node_id)s with error: %(error)s."), + {'node_id': node.uuid, 'error': e}) + else: + LOG.info(_LI("Successfully enabled boot config for node %(node_id)s."), + {'node_id': node.uuid}) + + +class AMTManagement(base.ManagementInterface): + + def get_properties(self): + return copy.deepcopy(amt_common.COMMON_PROPERTIES) + + def validate(self, task): + """Validate the driver_info in the node + + Check if the driver_info contains correct required fields + + :param task: a TaskManager instance contains the target node + :raises: MissingParameterValue if any required parameters are missing. + :raises: InvalidParameterValue if any parameters have invalid values. + """ + # FIXME(lintan): validate hangs if unable to reach AMT, so dont + # connect to the node until bug 1314961 is resolved. + amt_common.parse_driver_info(task.node) + + def get_supported_boot_devices(self): + """Get a list of the supported boot devices. + + :returns: A list with the supported boot devices. + """ + return list(amt_common.BOOT_DEVICES_MAPPING) + + @task_manager.require_exclusive_lock + def set_boot_device(self, task, device, persistent=False): + """Set the boot device for the task's node. + + Set the boot device to use on next boot of the node. + + :param task: a task from TaskManager. + :param device: the boot device + :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. + """ + node = task.node + + if device not in amt_common.BOOT_DEVICES_MAPPING: + raise exception.InvalidParameterValue(_("set_boot_device called " + "with invalid device %(device)s for node %(node_id)s.") % + {'device': device, 'node_id': node.uuid}) + + # AMT/vPro doesnt support set boot_device persistent, so we have to + # save amt_boot_device/amt_boot_persistent in driver_internal_info. + driver_internal_info = node.driver_internal_info + driver_internal_info['amt_boot_device'] = device + driver_internal_info['amt_boot_persistent'] = persistent + node.driver_internal_info = driver_internal_info + node.save() + + def get_boot_device(self, task): + """Get the current boot device for the task's node. + + Returns the current boot device of the node. + + :param task: a task from TaskManager. + :returns: a dictionary containing: + :boot_device: the boot device + :persistent: Whether the boot device will persist to all + future boots or not, None if it is unknown. + """ + driver_internal_info = task.node.driver_internal_info + device = driver_internal_info.get('amt_boot_device') + persistent = driver_internal_info.get('amt_boot_persistent') + if not device: + device = amt_common.DEFAULT_BOOT_DEVICE + persistent = True + return {'boot_device': device, + 'persistent': persistent} + + def ensure_next_boot_device(self, node, boot_device): + """Set next boot device (one time only) of AMT Client. + + :param node: a node object + :param boot_device: the boot device + :raises: AMTFailure + :raises: AMTConnectFailure + """ + driver_internal_info = node.driver_internal_info + if not driver_internal_info.get('amt_boot_persistent'): + driver_internal_info['amt_boot_device'] = ( + amt_common.DEFAULT_BOOT_DEVICE) + driver_internal_info['amt_boot_persistent'] = True + node.driver_internal_info = driver_internal_info + node.save() + + _set_boot_device_order(node, boot_device) + _enable_boot_config(node) + + def get_sensors_data(self, task): + raise NotImplementedError() diff --git a/ironic/drivers/modules/amt/power.py b/ironic/drivers/modules/amt/power.py new file mode 100644 index 0000000000..1ef96258af --- /dev/null +++ b/ironic/drivers/modules/amt/power.py @@ -0,0 +1,266 @@ +# +# 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. + +""" +AMT Power Driver +""" +import copy + +from oslo_config import cfg +from oslo_utils import excutils +from oslo_utils import importutils + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common.i18n import _LE +from ironic.common.i18n import _LI +from ironic.common.i18n import _LW +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers import base +from ironic.drivers.modules.amt import common as amt_common +from ironic.drivers.modules.amt import resource_uris +from ironic.openstack.common import log as logging +from ironic.openstack.common import loopingcall + +pywsman = importutils.try_import('pywsman') + +opts = [ + cfg.IntOpt('max_attempts', + default=3, + help='Maximum number of times to attempt an AMT operation, ' + 'before failing'), + cfg.IntOpt('action_wait', + default=10, + help='Amount of time (in seconds) to wait, before retrying ' + 'an AMT operation') +] + +CONF = cfg.CONF +CONF.register_opts(opts, group='amt') + +LOG = logging.getLogger(__name__) + +AMT_POWER_MAP = { + states.POWER_ON: '2', + states.POWER_OFF: '8', +} + + +def _generate_power_action_input(action): + """Generate Xmldoc as set_power_state input. + + This generates a Xmldoc used as input for set_power_state. + + :param action: the power action. + :returns: Xmldoc. + """ + method_input = "RequestPowerStateChange_INPUT" + address = 'http://schemas.xmlsoap.org/ws/2004/08/addressing' + anonymous = ('http://schemas.xmlsoap.org/ws/2004/08/addressing/' + 'role/anonymous') + wsman = 'http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd' + namespace = resource_uris.CIM_PowerManagementService + + doc = pywsman.XmlDoc(method_input) + root = doc.root() + root.set_ns(namespace) + root.add(namespace, 'PowerState', action) + + child = root.add(namespace, 'ManagedElement', None) + child.add(address, 'Address', anonymous) + + grand_child = child.add(address, 'ReferenceParameters', None) + grand_child.add(wsman, 'ResourceURI', resource_uris.CIM_ComputerSystem) + + g_grand_child = grand_child.add(wsman, 'SelectorSet', None) + g_g_grand_child = g_grand_child.add(wsman, 'Selector', 'ManagedSystem') + g_g_grand_child.attr_add(wsman, 'Name', 'Name') + return doc + + +def _set_power_state(node, target_state): + """Set power state of the AMT Client. + + :param node: a node object. + :param target_state: desired power state. + :raises: AMTFailure + :raises: AMTConnectFailure + """ + client = amt_common.get_wsman_client(node) + + method = 'RequestPowerStateChange' + options = pywsman.ClientOptions() + options.add_selector('Name', 'Intel(r) AMT Power Management Service') + + doc = _generate_power_action_input(AMT_POWER_MAP[target_state]) + try: + client.wsman_invoke(options, resource_uris.CIM_PowerManagementService, + method, doc) + except (exception.AMTFailure, exception.AMTConnectFailure) as e: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE("Failed to set power state %(state)s for " + "node %(node_id)s with error: %(error)s."), + {'state': target_state, 'node_id': node.uuid, + 'error': e}) + else: + LOG.info(_LI("Power state set to %(state)s for node %(node_id)s"), + {'state': target_state, 'node_id': node.uuid}) + + +def _power_status(node): + """Get the power status for a node. + + :param node: a node object. + :returns: one of ironic.common.states POWER_OFF, POWER_ON or ERROR. + :raises: AMTFailure. + :raises: AMTConnectFailure. + + """ + client = amt_common.get_wsman_client(node) + namespace = resource_uris.CIM_AssociatedPowerManagementService + try: + doc = client.wsman_get(namespace) + except (exception.AMTFailure, exception.AMTConnectFailure) as e: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE("Failed to get power state for node %(node_id)s " + "with error: %(error)s."), + {'node_id': node.uuid, 'error': e}) + + item = "PowerState" + power_state = amt_common.xml_find(doc, namespace, item).text + for state in AMT_POWER_MAP: + if power_state == AMT_POWER_MAP[state]: + return state + return states.ERROR + + +def _set_and_wait(task, target_state): + """Helper function for DynamicLoopingCall. + + This method changes the power state and polls AMT until the desired + power state is reached. + + :param task: a TaskManager instance contains the target node. + :param target_state: desired power state. + :returns: one of ironic.common.states. + :raises: PowerStateFailure if cannot set the node to target_state. + :raises: AMTFailure. + :raises: AMTConnectFailure + :raises: InvalidParameterValue + """ + node = task.node + driver = task.driver + if target_state not in (states.POWER_ON, states.POWER_OFF): + raise exception.InvalidParameterValue(_('Unsupported target_state: %s') + % target_state) + elif target_state == states.POWER_ON: + boot_device = node.driver_internal_info.get('amt_boot_device') + if boot_device and boot_device != amt_common.DEFAULT_BOOT_DEVICE: + driver.management.ensure_next_boot_device(node, boot_device) + + def _wait(status): + status['power'] = _power_status(node) + if status['power'] == target_state: + raise loopingcall.LoopingCallDone() + + if status['iter'] >= CONF.amt.max_attempts: + status['power'] = states.ERROR + LOG.warning(_LW("AMT failed to set power state %(state)s after " + "%(tries)s retries on node %(node_id)s."), + {'state': target_state, 'tries': status['iter'], + 'node_id': node.uuid}) + raise loopingcall.LoopingCallDone() + + try: + _set_power_state(node, target_state) + except Exception: + # Log failures but keep trying + LOG.warning(_LW("AMT set power state %(state)s for node %(node)s " + "- Attempt %(attempt)s times of %(max_attempt)s " + "failed."), + {'state': target_state, 'node': node.uuid, + 'attempt': status['iter'] + 1, + 'max_attempt': CONF.amt.max_attempts}) + status['iter'] += 1 + + status = {'power': None, 'iter': 0} + + timer = loopingcall.FixedIntervalLoopingCall(_wait, status) + timer.start(interval=CONF.amt.action_wait).wait() + + if status['power'] != target_state: + raise exception.PowerStateFailure(pstate=target_state) + + return status['power'] + + +class AMTPower(base.PowerInterface): + """AMT Power interface. + + This Power interface control the power of node by providing power on/off + and reset functions. + """ + + def get_properties(self): + return copy.deepcopy(amt_common.COMMON_PROPERTIES) + + def validate(self, task): + """Validate the driver_info in the node. + + Check if the driver_info contains correct required fields + + :param task: a TaskManager instance contains the target node. + :raises: MissingParameterValue if any required parameters are missing. + :raises: InvalidParameterValue if any parameters have invalid values. + """ + # FIXME(lintan): validate hangs if unable to reach AMT, so dont + # connect to the node until bug 1314961 is resolved. + amt_common.parse_driver_info(task.node) + + def get_power_state(self, task): + """Get the power state from the node. + + :param task: a TaskManager instance contains the target node. + :raises: AMTFailure. + :raises: AMTConnectFailure. + """ + return _power_status(task.node) + + @task_manager.require_exclusive_lock + def set_power_state(self, task, pstate): + """Set the power state of the node. + + Turn the node power on or off. + + :param task: a TaskManager instance contains the target node. + :param pstate : The desired power state of the node. + :raises: PowerStateFailure if the power cannot set to pstate. + :raises: AMTFailure. + :raises: AMTConnectFailure. + :raises: InvalidParameterValue + """ + _set_and_wait(task, pstate) + + @task_manager.require_exclusive_lock + def reboot(self, task): + """Cycle the power of the node + + :param task: a TaskManager instance contains the target node. + :raises: PowerStateFailure if failed to reboot. + :raises: AMTFailure. + :raises: AMTConnectFailure. + :raises: InvalidParameterValue + """ + _set_and_wait(task, states.POWER_OFF) + _set_and_wait(task, states.POWER_ON) diff --git a/ironic/drivers/modules/amt/vendor.py b/ironic/drivers/modules/amt/vendor.py new file mode 100644 index 0000000000..ffa0a2ffa0 --- /dev/null +++ b/ironic/drivers/modules/amt/vendor.py @@ -0,0 +1,30 @@ +# +# 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. +""" +AMT Vendor Methods +""" + +from ironic.common import boot_devices +from ironic.conductor import task_manager +from ironic.drivers import base +from ironic.drivers.modules import pxe + + +class AMTPXEVendorPassthru(pxe.VendorPassthru): + + @base.passthru(['POST'], method='pass_deploy_info') + @task_manager.require_exclusive_lock + def _continue_deploy(self, task, **kwargs): + task.driver.management.ensure_next_boot_device(task.node, + boot_devices.PXE) + super(AMTPXEVendorPassthru, self)._continue_deploy(task, **kwargs) diff --git a/ironic/drivers/pxe.py b/ironic/drivers/pxe.py index 3af2ee2e43..39afdeddcf 100644 --- a/ironic/drivers/pxe.py +++ b/ironic/drivers/pxe.py @@ -22,6 +22,9 @@ from oslo_utils import importutils from ironic.common import exception from ironic.common.i18n import _ from ironic.drivers import base +from ironic.drivers.modules.amt import management as amt_management +from ironic.drivers.modules.amt import power as amt_power +from ironic.drivers.modules.amt import vendor as amt_vendor from ironic.drivers.modules import iboot from ironic.drivers.modules.ilo import deploy as ilo_deploy from ironic.drivers.modules.ilo import management as ilo_management @@ -234,3 +237,23 @@ class PXEAndVirtualBoxDriver(base.BaseDriver): self.deploy = pxe.PXEDeploy() self.management = virtualbox.VirtualBoxManagement() self.vendor = pxe.VendorPassthru() + + +class PXEAndAMTDriver(base.BaseDriver): + """PXE + AMT driver. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.amt.AMTPower` for power on/off and reboot with + :class:`ironic.driver.pxe.PXE` 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('pywsman'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason=_("Unable to import pywsman library")) + self.power = amt_power.AMTPower() + self.deploy = pxe.PXEDeploy() + self.management = amt_management.AMTManagement() + self.vendor = amt_vendor.AMTPXEVendorPassthru() diff --git a/ironic/tests/drivers/amt/test_management.py b/ironic/tests/drivers/amt/test_management.py new file mode 100644 index 0000000000..6fd157c7f7 --- /dev/null +++ b/ironic/tests/drivers/amt/test_management.py @@ -0,0 +1,223 @@ +# +# 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 AMT ManagementInterface +""" + +import mock +from oslo_config import cfg + +from ironic.common import boot_devices +from ironic.common import exception +from ironic.conductor import task_manager +from ironic.drivers.modules.amt import common as amt_common +from ironic.drivers.modules.amt import management as amt_mgmt +from ironic.drivers.modules.amt import resource_uris +from ironic.tests.conductor import utils as mgr_utils +from ironic.tests.db import base as db_base +from ironic.tests.db import utils as db_utils +from ironic.tests.drivers.drac import utils as test_utils +from ironic.tests.objects import utils as obj_utils + +INFO_DICT = db_utils.get_test_amt_info() +CONF = cfg.CONF + + +@mock.patch.object(amt_common, 'pywsman') +class AMTManagementInteralMethodsTestCase(db_base.DbTestCase): + + def setUp(self): + super(AMTManagementInteralMethodsTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_amt') + self.node = obj_utils.create_test_node(self.context, + driver='fake_amt', + driver_info=INFO_DICT) + + def test__set_boot_device_order(self, mock_client_pywsman): + namespace = resource_uris.CIM_BootConfigSetting + device = boot_devices.PXE + result_xml = test_utils.build_soap_xml([{'ReturnValue': '0'}], + namespace) + mock_xml = test_utils.mock_wsman_root(result_xml) + mock_pywsman = mock_client_pywsman.Client.return_value + mock_pywsman.invoke.return_value = mock_xml + + amt_mgmt._set_boot_device_order(self.node, device) + + mock_pywsman.invoke.assert_called_once_with(mock.ANY, + namespace, 'ChangeBootOrder') + + def test__set_boot_device_order_fail(self, mock_client_pywsman): + namespace = resource_uris.CIM_BootConfigSetting + device = boot_devices.PXE + result_xml = test_utils.build_soap_xml([{'ReturnValue': '2'}], + namespace) + mock_xml = test_utils.mock_wsman_root(result_xml) + mock_pywsman = mock_client_pywsman.Client.return_value + mock_pywsman.invoke.return_value = mock_xml + + self.assertRaises(exception.AMTFailure, + amt_mgmt._set_boot_device_order, self.node, device) + mock_pywsman.invoke.assert_called_once_with(mock.ANY, + namespace, 'ChangeBootOrder') + + mock_pywsman = mock_client_pywsman.Client.return_value + mock_pywsman.invoke.return_value = None + + self.assertRaises(exception.AMTConnectFailure, + amt_mgmt._set_boot_device_order, self.node, device) + + def test__enable_boot_config(self, mock_client_pywsman): + namespace = resource_uris.CIM_BootService + result_xml = test_utils.build_soap_xml([{'ReturnValue': '0'}], + namespace) + mock_xml = test_utils.mock_wsman_root(result_xml) + mock_pywsman = mock_client_pywsman.Client.return_value + mock_pywsman.invoke.return_value = mock_xml + + amt_mgmt._enable_boot_config(self.node) + + mock_pywsman.invoke.assert_called_once_with(mock.ANY, + namespace, 'SetBootConfigRole') + + def test__enable_boot_config_fail(self, mock_client_pywsman): + namespace = resource_uris.CIM_BootService + result_xml = test_utils.build_soap_xml([{'ReturnValue': '2'}], + namespace) + mock_xml = test_utils.mock_wsman_root(result_xml) + mock_pywsman = mock_client_pywsman.Client.return_value + mock_pywsman.invoke.return_value = mock_xml + + self.assertRaises(exception.AMTFailure, + amt_mgmt._enable_boot_config, self.node) + mock_pywsman.invoke.assert_called_once_with(mock.ANY, + namespace, 'SetBootConfigRole') + + mock_pywsman = mock_client_pywsman.Client.return_value + mock_pywsman.invoke.return_value = None + + self.assertRaises(exception.AMTConnectFailure, + amt_mgmt._enable_boot_config, self.node) + + +class AMTManagementTestCase(db_base.DbTestCase): + + def setUp(self): + super(AMTManagementTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_amt') + self.info = INFO_DICT + self.node = obj_utils.create_test_node(self.context, + driver='fake_amt', + driver_info=self.info) + + def test_get_properties(self): + expected = amt_common.COMMON_PROPERTIES + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual(expected, task.driver.get_properties()) + + @mock.patch.object(amt_common, 'parse_driver_info') + def test_validate(self, mock_drvinfo): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.driver.management.validate(task) + mock_drvinfo.assert_called_once_with(task.node) + + @mock.patch.object(amt_common, 'parse_driver_info') + def test_validate_fail(self, mock_drvinfo): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + mock_drvinfo.side_effect = exception.InvalidParameterValue('x') + self.assertRaises(exception.InvalidParameterValue, + task.driver.management.validate, + task) + + def test_get_supported_boot_devices(self): + expected = [boot_devices.PXE, boot_devices.DISK, boot_devices.CDROM] + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual( + sorted(expected), + sorted(task.driver.management.get_supported_boot_devices())) + + def test_set_boot_device_one_time(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.management.set_boot_device(task, 'pxe') + self.assertEqual('pxe', + task.node.driver_internal_info["amt_boot_device"]) + self.assertFalse( + task.node.driver_internal_info["amt_boot_persistent"]) + + def test_set_boot_device_persistent(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.management.set_boot_device(task, 'pxe', + persistent=True) + self.assertEqual('pxe', + task.node.driver_internal_info["amt_boot_device"]) + self.assertTrue( + task.node.driver_internal_info["amt_boot_persistent"]) + + def test_set_boot_device_fail(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.assertRaises(exception.InvalidParameterValue, + task.driver.management.set_boot_device, + task, 'fake-device') + + @mock.patch.object(amt_mgmt, '_enable_boot_config') + @mock.patch.object(amt_mgmt, '_set_boot_device_order') + def test_ensure_next_boot_device_one_time(self, mock_sbdo, mock_ebc): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + device = boot_devices.PXE + task.node.driver_internal_info['amt_boot_device'] = 'pxe' + task.driver.management.ensure_next_boot_device(task.node, device) + self.assertEqual('disk', + task.node.driver_internal_info["amt_boot_device"]) + self.assertTrue( + task.node.driver_internal_info["amt_boot_persistent"]) + mock_sbdo.assert_called_once_with(task.node, device) + mock_ebc.assert_called_once_with(task.node) + + @mock.patch.object(amt_mgmt, '_enable_boot_config') + @mock.patch.object(amt_mgmt, '_set_boot_device_order') + def test_ensure_next_boot_device_persistent(self, mock_sbdo, mock_ebc): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + device = boot_devices.PXE + task.node.driver_internal_info['amt_boot_device'] = 'pxe' + task.node.driver_internal_info['amt_boot_persistent'] = True + task.driver.management.ensure_next_boot_device(task.node, device) + self.assertEqual('pxe', + task.node.driver_internal_info["amt_boot_device"]) + self.assertTrue( + task.node.driver_internal_info["amt_boot_persistent"]) + mock_sbdo.assert_called_once_with(task.node, device) + mock_ebc.assert_called_once_with(task.node) + + def test_get_boot_device(self): + expected = {'boot_device': boot_devices.DISK, 'persistent': True} + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual(expected, + task.driver.management.get_boot_device(task)) + + def test_get_sensor_data(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertRaises(NotImplementedError, + task.driver.management.get_sensors_data, + task) diff --git a/ironic/tests/drivers/amt/test_power.py b/ironic/tests/drivers/amt/test_power.py new file mode 100644 index 0000000000..9b13d8d51c --- /dev/null +++ b/ironic/tests/drivers/amt/test_power.py @@ -0,0 +1,260 @@ +# +# 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 AMT ManagementInterface +""" + +import mock +from oslo_config import cfg + +from ironic.common import boot_devices +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers.modules.amt import common as amt_common +from ironic.drivers.modules.amt import power as amt_power +from ironic.drivers.modules.amt import resource_uris +from ironic.tests.conductor import utils as mgr_utils +from ironic.tests.db import base as db_base +from ironic.tests.db import utils as db_utils +from ironic.tests.drivers.drac import utils as test_utils +from ironic.tests.objects import utils as obj_utils + +INFO_DICT = db_utils.get_test_amt_info() +CONF = cfg.CONF + + +class AMTPowerInteralMethodsTestCase(db_base.DbTestCase): + + def setUp(self): + super(AMTPowerInteralMethodsTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_amt') + self.info = INFO_DICT + self.node = obj_utils.create_test_node(self.context, + driver='fake_amt', + driver_info=self.info) + CONF.set_override('max_attempts', 2, 'amt') + CONF.set_override('action_wait', 0, 'amt') + + @mock.patch.object(amt_common, 'get_wsman_client') + def test__set_power_state(self, mock_client_pywsman): + namespace = resource_uris.CIM_PowerManagementService + mock_client = mock_client_pywsman.return_value + amt_power._set_power_state(self.node, states.POWER_ON) + mock_client.wsman_invoke.assert_called_once_with(mock.ANY, + namespace, 'RequestPowerStateChange', mock.ANY) + + @mock.patch.object(amt_common, 'get_wsman_client') + def test__set_power_state_fail(self, mock_client_pywsman): + mock_client = mock_client_pywsman.return_value + mock_client.wsman_invoke.side_effect = exception.AMTFailure('x') + self.assertRaises(exception.AMTFailure, + amt_power._set_power_state, + self.node, states.POWER_ON) + + @mock.patch.object(amt_common, 'get_wsman_client') + def test__power_status(self, mock_gwc): + namespace = resource_uris.CIM_AssociatedPowerManagementService + result_xml = test_utils.build_soap_xml([{'PowerState': + '2'}], + namespace) + mock_doc = test_utils.mock_wsman_root(result_xml) + mock_client = mock_gwc.return_value + mock_client.wsman_get.return_value = mock_doc + self.assertEqual( + states.POWER_ON, amt_power._power_status(self.node)) + + result_xml = test_utils.build_soap_xml([{'PowerState': + '8'}], + namespace) + mock_doc = test_utils.mock_wsman_root(result_xml) + mock_client = mock_gwc.return_value + mock_client.wsman_get.return_value = mock_doc + self.assertEqual( + states.POWER_OFF, amt_power._power_status(self.node)) + + result_xml = test_utils.build_soap_xml([{'PowerState': + '4'}], + namespace) + mock_doc = test_utils.mock_wsman_root(result_xml) + mock_client = mock_gwc.return_value + mock_client.wsman_get.return_value = mock_doc + self.assertEqual( + states.ERROR, amt_power._power_status(self.node)) + + @mock.patch.object(amt_common, 'get_wsman_client') + def test__power_status_fail(self, mock_gwc): + mock_client = mock_gwc.return_value + mock_client.wsman_get.side_effect = exception.AMTFailure('x') + self.assertRaises(exception.AMTFailure, + amt_power._power_status, + self.node) + + @mock.patch.object(amt_power, '_power_status') + @mock.patch.object(amt_power, '_set_power_state') + def test__set_and_wait_power_on_with_boot_device(self, mock_sps, + mock_ps): + mock_snbd = mock.Mock() + target_state = states.POWER_ON + boot_device = boot_devices.PXE + mock_ps.side_effect = [states.POWER_OFF, states.POWER_ON] + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.node.driver_internal_info['amt_boot_device'] = boot_device + task.driver.management.ensure_next_boot_device = mock_snbd + mock_snbd.return_value = None + self.assertEqual(states.POWER_ON, + amt_power._set_and_wait(task, target_state)) + mock_snbd.assert_called_with(task.node, boot_devices.PXE) + mock_sps.assert_called_once_with(task.node, states.POWER_ON) + mock_ps.assert_called_with(task.node) + + @mock.patch.object(amt_power, '_power_status') + @mock.patch.object(amt_power, '_set_power_state') + def test__set_and_wait_power_on_without_boot_device(self, mock_sps, + mock_ps): + target_state = states.POWER_ON + mock_ps.side_effect = [states.POWER_OFF, states.POWER_ON] + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual(states.POWER_ON, + amt_power._set_and_wait(task, target_state)) + mock_sps.assert_called_once_with(task.node, states.POWER_ON) + mock_ps.assert_called_with(task.node) + + boot_device = boot_devices.DISK + self.node.driver_internal_info['amt_boot_device'] = boot_device + mock_ps.side_effect = [states.POWER_OFF, states.POWER_ON] + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual(states.POWER_ON, + amt_power._set_and_wait(task, target_state)) + mock_sps.assert_called_with(task.node, states.POWER_ON) + mock_ps.assert_called_with(task.node) + + def test__set_and_wait_wrong_target_state(self): + target_state = 'fake-state' + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertRaises(exception.InvalidParameterValue, + amt_power._set_and_wait, task, target_state) + + @mock.patch.object(amt_power, '_power_status') + @mock.patch.object(amt_power, '_set_power_state') + def test__set_and_wait_exceed_iterations(self, mock_sps, + mock_ps): + target_state = states.POWER_ON + mock_ps.side_effect = [states.POWER_OFF, states.POWER_OFF, + states.POWER_OFF] + mock_sps.return_value = exception.AMTFailure('x') + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertRaises(exception.PowerStateFailure, + amt_power._set_and_wait, task, target_state) + mock_sps.assert_called_with(task.node, states.POWER_ON) + mock_ps.assert_called_with(task.node) + self.assertEqual(3, mock_ps.call_count) + + @mock.patch.object(amt_power, '_power_status') + def test__set_and_wait_already_target_state(self, mock_ps): + target_state = states.POWER_ON + mock_ps.side_effect = [states.POWER_ON] + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual(states.POWER_ON, + amt_power._set_and_wait(task, target_state)) + mock_ps.assert_called_with(task.node) + + @mock.patch.object(amt_power, '_power_status') + @mock.patch.object(amt_power, '_set_power_state') + def test__set_and_wait_power_off(self, mock_sps, mock_ps): + target_state = states.POWER_OFF + mock_ps.side_effect = [states.POWER_ON, states.POWER_OFF] + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual(states.POWER_OFF, + amt_power._set_and_wait(task, target_state)) + mock_sps.assert_called_once_with(task.node, states.POWER_OFF) + mock_ps.assert_called_with(task.node) + + +class AMTPowerTestCase(db_base.DbTestCase): + + def setUp(self): + super(AMTPowerTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_amt') + self.info = INFO_DICT + self.node = obj_utils.create_test_node(self.context, + driver='fake_amt', + driver_info=self.info) + + def test_get_properties(self): + expected = amt_common.COMMON_PROPERTIES + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual(expected, task.driver.get_properties()) + + @mock.patch.object(amt_common, 'parse_driver_info') + def test_validate(self, mock_drvinfo): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.driver.power.validate(task) + mock_drvinfo.assert_called_once_with(task.node) + + @mock.patch.object(amt_common, 'parse_driver_info') + def test_validate_fail(self, mock_drvinfo): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + mock_drvinfo.side_effect = exception.InvalidParameterValue('x') + self.assertRaises(exception.InvalidParameterValue, + task.driver.power.validate, + task) + + @mock.patch.object(amt_power, '_power_status') + def test_get_power_state(self, mock_ps): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + mock_ps.return_value = states.POWER_ON + self.assertEqual(states.POWER_ON, + task.driver.power.get_power_state(task)) + mock_ps.assert_called_once_with(task.node) + + @mock.patch.object(amt_power, '_set_and_wait') + def test_set_power_state(self, mock_saw): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + pstate = states.POWER_ON + mock_saw.return_value = states.POWER_ON + task.driver.power.set_power_state(task, pstate) + mock_saw.assert_called_once_with(task, pstate) + + @mock.patch.object(amt_power, '_set_and_wait') + def test_set_power_state_fail(self, mock_saw): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + pstate = states.POWER_ON + mock_saw.side_effect = exception.PowerStateFailure('x') + self.assertRaises(exception.PowerStateFailure, + task.driver.power.set_power_state, + task, pstate) + mock_saw.assert_called_once_with(task, pstate) + + @mock.patch.object(amt_power, '_set_and_wait') + def test_reboot(self, mock_saw): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.power.reboot(task) + calls = [mock.call(task, states.POWER_OFF), + mock.call(task, states.POWER_ON)] + mock_saw.has_calls(calls) diff --git a/ironic/tests/drivers/amt/test_vendor.py b/ironic/tests/drivers/amt/test_vendor.py new file mode 100644 index 0000000000..3c8f0eb23c --- /dev/null +++ b/ironic/tests/drivers/amt/test_vendor.py @@ -0,0 +1,65 @@ +# +# 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 AMT Vendor methods.""" + +import mock + +from ironic.common import boot_devices +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers.modules import pxe +from ironic.tests.conductor import utils as mgr_utils +from ironic.tests.db import base as db_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_amt_info() + + +class AMTPXEVendorPassthruTestCase(db_base.DbTestCase): + + def setUp(self): + super(AMTPXEVendorPassthruTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver="pxe_amt") + self.node = obj_utils.create_test_node(self.context, + driver='pxe_amt', driver_info=INFO_DICT) + + def test_vendor_routes(self): + expected = ['heartbeat', 'pass_deploy_info'] + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + vendor_routes = task.driver.vendor.vendor_routes + self.assertIsInstance(vendor_routes, dict) + self.assertEqual(sorted(expected), sorted(list(vendor_routes))) + + def test_driver_routes(self): + expected = ['lookup'] + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + driver_routes = task.driver.vendor.driver_routes + self.assertIsInstance(driver_routes, dict) + self.assertEqual(sorted(expected), sorted(list(driver_routes))) + + @mock.patch.object(pxe.VendorPassthru, '_continue_deploy') + def test_vendorpassthru_continue_deploy(self, mock_pxe_vendorpassthru): + mock_ensure = mock.Mock() + kwargs = {'address': '123456'} + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node.provision_state = states.DEPLOYWAIT + task.node.target_provision_state = states.ACTIVE + task.driver.management.ensure_next_boot_device = mock_ensure + task.driver.vendor._continue_deploy(task, **kwargs) + mock_ensure.assert_called_with(task.node, boot_devices.PXE) + mock_pxe_vendorpassthru.assert_called_once_with(task, **kwargs) diff --git a/ironic/tests/drivers/third_party_driver_mocks.py b/ironic/tests/drivers/third_party_driver_mocks.py index 87ced95dac..e1edf54cb5 100644 --- a/ironic/tests/drivers/third_party_driver_mocks.py +++ b/ironic/tests/drivers/third_party_driver_mocks.py @@ -97,16 +97,17 @@ if not proliantutils: # attempt to load the external 'pywsman' library, which is required by -# the optional drivers.modules.drac module +# the optional drivers.modules.drac and drivers.modules.amt 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']) + # Now that the external library has been mocked, if anything had already + # loaded any of the drivers, reload them. + if 'ironic.drivers.modules.drac' in sys.modules: + reload(sys.modules['ironic.drivers.modules.drac']) + if 'ironic.drivers.modules.amt' in sys.modules: + reload(sys.modules['ironic.drivers.modules.amt']) # attempt to load the external 'iboot' library, which is required by diff --git a/setup.cfg b/setup.cfg index fd886a0d5d..34d6d06bb0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,7 @@ ironic.drivers = fake_snmp = ironic.drivers.fake:FakeSNMPDriver fake_irmc = ironic.drivers.fake:FakeIRMCDriver fake_vbox = ironic.drivers.fake:FakeVirtualBoxDriver + fake_amt = ironic.drivers.fake:FakeAMTDriver iscsi_ilo = ironic.drivers.ilo:IloVirtualMediaIscsiDriver pxe_ipmitool = ironic.drivers.pxe:PXEAndIPMIToolDriver pxe_ipminative = ironic.drivers.pxe:PXEAndIPMINativeDriver @@ -64,6 +65,7 @@ ironic.drivers = pxe_drac = ironic.drivers.drac:PXEDracDriver pxe_snmp = ironic.drivers.pxe:PXEAndSNMPDriver pxe_irmc = ironic.drivers.pxe:PXEAndIRMCDriver + pxe_amt = ironic.drivers.pxe:PXEAndAMTDriver ironic.database.migration_backend = sqlalchemy = ironic.db.sqlalchemy.migration