diff --git a/ironic/drivers/drac.py b/ironic/drivers/drac.py index a74450d942..4f6b6dccee 100644 --- a/ironic/drivers/drac.py +++ b/ironic/drivers/drac.py @@ -21,8 +21,10 @@ from ironic.common.i18n import _ from ironic.drivers import base from ironic.drivers.modules.drac import management from ironic.drivers.modules.drac import power +from ironic.drivers.modules.drac import vendor_passthru from ironic.drivers.modules import inspector from ironic.drivers.modules import pxe +from ironic.drivers import utils class PXEDracDriver(base.BaseDriver): @@ -37,6 +39,16 @@ class PXEDracDriver(base.BaseDriver): self.power = power.DracPower() self.deploy = pxe.PXEDeploy() self.management = management.DracManagement() - self.vendor = pxe.VendorPassthru() + self.pxe_vendor = pxe.VendorPassthru() + self.drac_vendor = vendor_passthru.DracVendorPassthru() + self.mapping = {'pass_deploy_info': self.pxe_vendor, + 'heartbeat': self.pxe_vendor, + 'pass_bootloader_install_info': self.pxe_vendor, + 'get_bios_config': self.drac_vendor, + 'set_bios_config': self.drac_vendor, + 'commit_bios_config': self.drac_vendor, + 'abandon_bios_config': self.drac_vendor, + } + self.vendor = utils.MixinVendorInterface(self.mapping) self.inspect = inspector.Inspector.create_if_enabled( 'PXEDracDriver') diff --git a/ironic/drivers/modules/drac/bios.py b/ironic/drivers/modules/drac/bios.py new file mode 100644 index 0000000000..a74f01e998 --- /dev/null +++ b/ironic/drivers/modules/drac/bios.py @@ -0,0 +1,430 @@ +# +# 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 Bios specific methods +""" + +import re +from xml.etree import ElementTree as ET + +from oslo_log import log as logging +from oslo_utils import excutils + +from ironic.common import exception +from ironic.common.i18n import _, _LE, _LW +from ironic.conductor import task_manager +from ironic.drivers.modules.drac import client as wsman_client +from ironic.drivers.modules.drac import management +from ironic.drivers.modules.drac import resource_uris + +LOG = logging.getLogger(__name__) + + +def _val_or_none(item): + """Test to see if an XML element should be treated as None. + + If the element contains an XML Schema namespaced nil attribute that + has a value of True, return None. Otherwise, return whatever the + text of the element is. + + :param item: an XML element. + :returns: None or the test of the XML element. + """ + + if item is None: + return + itemnil = item.attrib.get('{%s}nil' % resource_uris.CIM_XmlSchema) + if itemnil == "true": + return + else: + return item.text + + +def _parse_common(item, ns): + """Parse common values that all attributes must have. + + :param item: an XML element. + :param ns: the namespace to search. + :returns: a dictionary containing the parsed attributes of the element. + :raises: DracOperationFailed if the given element had no AttributeName + value. + """ + searches = {'current_value': './{%s}CurrentValue' % ns, + 'read_only': './{%s}IsReadOnly' % ns, + 'pending_value': './{%s}PendingValue' % ns} + LOG.debug("Handing %(ns)s for %(xml)s", { + 'ns': ns, + 'xml': ET.tostring(item), + }) + name = item.findtext('./{%s}AttributeName' % ns) + if not name: + raise exception.DracOperationFailed( + message=_('Item has no name: "%s"') % ET.tostring(item)) + res = {} + res['name'] = name + + for k in searches: + if k == 'read_only': + res[k] = item.findtext(searches[k]) == 'true' + else: + res[k] = _val_or_none(item.find(searches[k])) + return res + + +def _format_error_msg(invalid_attribs_msgs, read_only_keys): + """Format a combined error message. + + This method creates a combined error message from a list of error messages + and a list of read-only keys. + + :param invalid_attribs_msgs: a list of invalid attribute error messages. + :param read_only_keys: a list of read only keys that were attempted to be + written to. + :returns: a formatted error message. + """ + msg = '\n'.join(invalid_attribs_msgs) + if invalid_attribs_msgs and read_only_keys: + msg += '\n' + if read_only_keys: + msg += (_('Cannot set read-only BIOS settings "%r"') % read_only_keys) + return msg + + +def parse_enumeration(item, ns): + """Parse an attribute that has a set of distinct values. + + :param item: an XML element. + :param ns: the namespace to search. + :returns: a dictionary containing the parsed attributes of the element. + :raises: DracOperationFailed if the given element had no AttributeName + value. + """ + res = _parse_common(item, ns) + res['possible_values'] = sorted( + [v.text for v in item.findall('./{%s}PossibleValues' % ns)]) + + return res + + +def parse_string(item, ns): + """Parse an attribute that should be a freeform string. + + :param item: an XML element. + :param ns: the namespace to search. + :returns: a dictionary containing the parsed attributes of the element. + :raises: DracOperationFailed if the given element had no AttributeName + value. + """ + res = _parse_common(item, ns) + searches = {'min_length': './{%s}MinLength' % ns, + 'max_length': './{%s}MaxLength' % ns, + 'pcre_regex': './{%s}ValueExpression' % ns} + for k in searches: + if k == 'pcre_regex': + res[k] = _val_or_none(item.find(searches[k])) + else: + res[k] = int(item.findtext(searches[k])) + + # Workaround for a BIOS bug in one of the 13 gen boxes + badval = re.compile(r"MAX_ASSET_TAG_LEN") + if (res['pcre_regex'] is not None and + res['name'] == 'AssetTag' and + badval.search(res['pcre_regex'])): + res['pcre_regex'] = badval.sub("%d" % res['max_length'], + res['pcre_regex']) + return res + + +def parse_integer(item, ns): + """Parse an attribute that should be an integer. + + :param item: an XML element. + :param ns: the namespace to search. + :returns: a dictionary containing the parsed attributes of the element. + :raises: DracOperationFailed if the given element had no AttributeName + value. + """ + res = _parse_common(item, ns) + for k in ['current_value', 'pending_value']: + if res[k]: + res[k] = int(res[k]) + searches = {'lower_bound': './{%s}LowerBound' % ns, + 'upper_bound': './{%s}UpperBound' % ns} + for k in searches: + res[k] = int(item.findtext(searches[k])) + + return res + + +def _get_config(node, resource): + """Helper for get_config. + + Handles getting BIOS config values for a single namespace + + :param node: an ironic node object. + :param resource: the namespace. + :returns: a dictionary that maps the name of each attribute to a dictionary + of values of that attribute. + :raises: InvalidParameterValue if some information required to connnect + to the DRAC is missing on the node or the value of one or more + required parameters is invalid. + :raises: DracClientError on an error from pywsman library. + :raises: DracOperationFailed if the specified resource is unknown. + """ + res = {} + client = wsman_client.get_wsman_client(node) + try: + doc = client.wsman_enumerate(resource) + except exception.DracClientError as exc: + with excutils.save_and_reraise_exception(): + LOG.error(_LE('DRAC driver failed to get BIOS settings ' + 'for resource %(resource)s ' + 'from node %(node_uuid)s. ' + 'Reason: %(error)s.'), + {'node_uuid': node.uuid, + 'resource': resource, + 'error': exc}) + items = doc.find('.//{%s}Items' % resource_uris.CIM_WSMAN) + for item in items: + if resource == resource_uris.DCIM_BIOSEnumeration: + attribute = parse_enumeration(item, resource) + elif resource == resource_uris.DCIM_BIOSString: + attribute = parse_string(item, resource) + elif resource == resource_uris.DCIM_BIOSInteger: + attribute = parse_integer(item, resource) + else: + raise exception.DracOperationFailed( + message=_('Unknown namespace %(ns)s for item: "%(item)s"') % { + 'item': ET.tostring(item), 'ns': resource}) + res[attribute['name']] = attribute + return res + + +def get_config(node): + """Get the BIOS configuration from a Dell server using WSMAN + + :param node: an ironic node object. + :raises: DracClientError on an error from pywsman. + :raises: DracOperationFailed when a BIOS setting cannot be parsed. + :returns: a dictionary containing BIOS settings in the form of: + {'EnumAttrib': {'name': 'EnumAttrib', + 'current_value': 'Value', + 'pending_value': 'New Value', # could also be None + 'read_only': False, + 'possible_values': ['Value', 'New Value', 'None']}, + 'StringAttrib': {'name': 'StringAttrib', + 'current_value': 'Information', + 'pending_value': None, + 'read_only': False, + 'min_length': 0, + 'max_length': 255, + 'pcre_regex': '^[0-9A-Za-z]{0,255}$'}, + 'IntegerAttrib': {'name': 'IntegerAttrib', + 'current_value': 0, + 'pending_value': None, + 'read_only': True, + 'lower_bound': 0, + 'upper_bound': 65535} + } + + The above values are only examples, of course. BIOS attributes exposed via + this API will always be either an enumerated attribute, a string attribute, + or an integer attribute. All attributes have the following parameters: + :name: is the name of the BIOS attribute. + :current_value: is the current value of the attribute. + It will always be either an integer or a string. + :pending_value: is the new value that we want the attribute to have. + None means that there is no pending value. + :read_only: indicates whether this attribute can be changed. Trying to + change a read-only value will result in an error. + The read-only flag can change depending on other attributes. + A future version of this call may expose the dependencies + that indicate when that may happen. + + Enumerable attributes also have the following parameters: + :possible_values: is an array of values it is permissible to set + the attribute to. + + String attributes also have the following parameters: + :min_length: is the minimum length of the string. + :max_length: is the maximum length of the string. + :pcre_regex: is a PCRE compatible regular expression that the string + must match. It may be None if the string is read only + or if the string does not have to match any particular + regular expression. + + Integer attributes also have the following parameters: + :lower_bound: is the minimum value the attribute can have. + :upper_bound: is the maximum value the attribute can have. + + """ + res = {} + for ns in [resource_uris.DCIM_BIOSEnumeration, + resource_uris.DCIM_BIOSString, + resource_uris.DCIM_BIOSInteger]: + attribs = _get_config(node, ns) + if not set(res).isdisjoint(set(attribs)): + raise exception.DracOperationFailed( + message=_('Colliding attributes %r') % ( + set(res) & set(attribs))) + res.update(attribs) + return res + + +@task_manager.require_exclusive_lock +def set_config(task, **kwargs): + """Sets the pending_value parameter for each of the values passed in. + + :param task: an ironic task object. + :param kwargs: a dictionary of {'AttributeName': 'NewValue'} + :raises: DracOperationFailed if any new values are invalid. + :raises: DracOperationFailed if any of the attributes are read-only. + :raises: DracOperationFailed if any of the attributes cannot be set for + any other reason. + :raises: DracClientError on an error from the pywsman library. + :returns: A boolean indicating whether commit_config needs to be + called to make the changes. + + """ + node = task.node + management.check_for_config_job(node) + current = get_config(node) + unknown_keys = set(kwargs) - set(current) + if unknown_keys: + LOG.warning(_LW('Ignoring unknown BIOS attributes "%r"'), + unknown_keys) + + candidates = set(kwargs) - unknown_keys + read_only_keys = [] + unchanged_attribs = [] + invalid_attribs_msgs = [] + attrib_names = [] + + for k in candidates: + if str(kwargs[k]) == str(current[k]['current_value']): + unchanged_attribs.append(k) + elif current[k]['read_only']: + read_only_keys.append(k) + else: + if 'possible_values' in current[k]: + if str(kwargs[k]) not in current[k]['possible_values']: + m = _('Attribute %(attr)s cannot be set to value %(val)s.' + ' It must be in %(ok)r') % { + 'attr': k, + 'val': kwargs[k], + 'ok': current[k]['possible_values']} + invalid_attribs_msgs.append(m) + continue + if ('pcre_regex' in current[k] and + current[k]['pcre_regex'] is not None): + regex = re.compile(current[k]['pcre_regex']) + if regex.search(str(kwargs[k])) is None: + # TODO(victor-lowther) + # Leave untranslated for now until the unicode + # issues that the test suite exposes are straightened out. + m = ('Attribute %(attr)s cannot be set to value %(val)s.' + ' It must match regex %(re)s.') % { + 'attr': k, + 'val': kwargs[k], + 're': current[k]['pcre_regex']} + invalid_attribs_msgs.append(m) + continue + if 'lower_bound' in current[k]: + lower = current[k]['lower_bound'] + upper = current[k]['upper_bound'] + val = int(kwargs[k]) + if val < lower or val > upper: + m = _('Attribute %(attr)s cannot be set to value %(val)d.' + ' It must be between %(lower)d and %(upper)d.') % { + 'attr': k, + 'val': val, + 'lower': lower, + 'upper': upper} + invalid_attribs_msgs.append(m) + continue + attrib_names.append(k) + + if unchanged_attribs: + LOG.warn(_LW('Ignoring unchanged BIOS settings %r'), + unchanged_attribs) + + if invalid_attribs_msgs or read_only_keys: + raise exception.DracOperationFailed( + _format_error_msg(invalid_attribs_msgs, read_only_keys)) + + if not attrib_names: + return False + + client = wsman_client.get_wsman_client(node) + selectors = {'CreationClassName': 'DCIM_BIOSService', + 'Name': 'DCIM:BIOSService', + 'SystemCreationClassName': 'DCIM_ComputerSystem', + 'SystemName': 'DCIM:ComputerSystem'} + properties = {'Target': 'BIOS.Setup.1-1', + 'AttributeName': attrib_names, + 'AttributeValue': map(lambda k: kwargs[k], attrib_names)} + doc = client.wsman_invoke(resource_uris.DCIM_BIOSService, + 'SetAttributes', + selectors, + properties) + # Yes, we look for RebootRequired. In this context, that actually means + # that we need to create a lifecycle controller config job and then reboot + # so that the lifecycle controller can commit the BIOS config changes that + # we have proposed. + set_results = doc.findall( + './/{%s}RebootRequired' % resource_uris.DCIM_BIOSService) + return any(str(res.text) == 'Yes' for res in set_results) + + +@task_manager.require_exclusive_lock +def commit_config(task): + """Commits pending changes added by set_config + + :param task: is the ironic task for running the config job. + :raises: DracClientError on an error from pywsman library. + :raises: DracPendingConfigJobExists if the job is already created. + :raises: DracOperationFailed if the client received response with an + error message. + :raises: DracUnexpectedReturnValue if the client received a response + with unexpected return value + + """ + node = task.node + management.check_for_config_job(node) + management.create_config_job(node) + + +@task_manager.require_exclusive_lock +def abandon_config(task): + """Abandons uncommitted changes added by set_config + + :param task: is the ironic task for abandoning the changes. + :raises: DracClientError on an error from pywsman library. + :raises: DracOperationFailed on error reported back by DRAC. + :raises: DracUnexpectedReturnValue if the drac did not report success. + + """ + node = task.node + client = wsman_client.get_wsman_client(node) + selectors = {'CreationClassName': 'DCIM_BIOSService', + 'Name': 'DCIM:BIOSService', + 'SystemCreationClassName': 'DCIM_ComputerSystem', + 'SystemName': 'DCIM:ComputerSystem'} + properties = {'Target': 'BIOS.Setup.1-1'} + + client.wsman_invoke(resource_uris.DCIM_BIOSService, + 'DeletePendingConfiguration', + selectors, + properties, + wsman_client.RET_SUCCESS) diff --git a/ironic/drivers/modules/drac/client.py b/ironic/drivers/modules/drac/client.py index 5b8b4b9411..58c62c59dc 100644 --- a/ironic/drivers/modules/drac/client.py +++ b/ironic/drivers/modules/drac/client.py @@ -147,6 +147,8 @@ class Client(object): doc = retry_on_empty_response(self.client, 'enumerate', options, filter_, resource_uri) root = self._get_root(doc) + LOG.debug("WSMAN enumerate returned raw XML: %s", + ElementTree.tostring(root)) final_xml = root find_query = './/{%s}Body' % _SOAP_ENVELOPE_URI @@ -155,6 +157,9 @@ class Client(object): doc = retry_on_empty_response(self.client, 'pull', options, None, resource_uri, str(doc.context())) root = self._get_root(doc) + LOG.debug("WSMAN pull returned raw XML: %s", + ElementTree.tostring(root)) + for result in root.findall(find_query): for child in list(result): insertion_point.append(child) @@ -162,14 +167,14 @@ class Client(object): return final_xml def wsman_invoke(self, resource_uri, method, selectors=None, - properties=None, expected_return_value=RET_SUCCESS): + properties=None, expected_return=None): """Invokes a remote WS-Man method. :param resource_uri: URI of the resource. :param method: name of the method to invoke. :param selectors: dictionary of selectors. :param properties: dictionary of properties. - :param expected_return_value: expected return value. + :param expected_return: expected return value. :raises: DracClientError on an error from pywsman library. :raises: DracOperationFailed on error reported back by DRAC. :raises: DracUnexpectedReturnValue on return value mismatch. @@ -199,9 +204,16 @@ class Client(object): for name, value in properties.items(): if isinstance(value, list): for item in value: - xml_root.add(resource_uri, name, item) + xml_root.add(resource_uri, str(name), str(item)) else: xml_root.add(resource_uri, name, value) + LOG.debug(('WSMAN invoking: %(resource_uri)s:%(method)s' + '\nselectors: %(selectors)r\nxml: %(xml)s'), + { + 'resource_uri': resource_uri, + 'method': method, + 'selectors': selectors, + 'xml': xml_root.string()}) else: xml_doc = None @@ -209,22 +221,39 @@ class Client(object): for name, value in properties.items(): options.add_property(name, value) + LOG.debug(('WSMAN invoking: %(resource_uri)s:%(method)s' + '\nselectors: %(selectors)r\properties: %(props)r') % { + 'resource_uri': resource_uri, + 'method': method, + 'selectors': selectors, + 'props': properties}) + doc = retry_on_empty_response(self.client, 'invoke', options, resource_uri, method, xml_doc) - root = self._get_root(doc) + LOG.debug("WSMAN invoke returned raw XML: %s", + ElementTree.tostring(root)) return_value = drac_common.find_xml(root, 'ReturnValue', resource_uri).text - if return_value != expected_return_value: - if return_value == RET_ERROR: - message = drac_common.find_xml(root, 'Message', - resource_uri).text - raise exception.DracOperationFailed(message=message) + if return_value == RET_ERROR: + messages = drac_common.find_xml(root, 'Message', + resource_uri, True) + message_args = drac_common.find_xml(root, 'MessageArguments', + resource_uri, True) + + if message_args: + messages = [m.text % p.text for (m, p) in + zip(messages, message_args)] else: - raise exception.DracUnexpectedReturnValue( - expected_return_value=expected_return_value, - actual_return_value=return_value) + messages = [m.text for m in messages] + + raise exception.DracOperationFailed(message='%r' % messages) + + if expected_return and return_value != expected_return: + raise exception.DracUnexpectedReturnValue( + expected_return_value=expected_return, + actual_return_value=return_value) return root diff --git a/ironic/drivers/modules/drac/management.py b/ironic/drivers/modules/drac/management.py index e1dad97386..e69a435733 100644 --- a/ironic/drivers/modules/drac/management.py +++ b/ironic/drivers/modules/drac/management.py @@ -221,7 +221,7 @@ def _get_boot_list_for_boot_device(node, device, controller_version): return {'boot_list': boot_list, 'boot_device_id': boot_device_id} -def _create_config_job(node): +def create_config_job(node): """Create a configuration job. This method is used to apply the pending values created by @@ -253,7 +253,7 @@ def _create_config_job(node): {'node_uuid': node.uuid, 'error': exc}) -def _check_for_config_job(node): +def check_for_config_job(node): """Check if a configuration job is already created. :param node: an ironic node object. @@ -379,7 +379,7 @@ class DracManagement(base.ManagementInterface): return # Check for an existing configuration job - _check_for_config_job(task.node) + check_for_config_job(task.node) # Querying the boot device attributes boot_device = _get_boot_list_for_boot_device(task.node, device, @@ -396,7 +396,7 @@ class DracManagement(base.ManagementInterface): try: client.wsman_invoke(resource_uris.DCIM_BootConfigSetting, 'ChangeBootOrderByInstanceID', selectors, - properties) + properties, drac_client.RET_SUCCESS) except exception.DracRequestFailed as exc: with excutils.save_and_reraise_exception(): LOG.error(_LE('DRAC driver failed to set the boot device for ' @@ -407,7 +407,7 @@ class DracManagement(base.ManagementInterface): 'error': exc}) # Create a configuration job - _create_config_job(task.node) + create_config_job(task.node) def get_boot_device(self, task): """Get the current boot device for a node. diff --git a/ironic/drivers/modules/drac/resource_uris.py b/ironic/drivers/modules/drac/resource_uris.py index 03a5385cb0..d8e2369c09 100644 --- a/ironic/drivers/modules/drac/resource_uris.py +++ b/ironic/drivers/modules/drac/resource_uris.py @@ -28,8 +28,19 @@ DCIM_BootConfigSetting = ('http://schemas.dell.com/wbem/wscim/1/cim-schema/2/' DCIM_BIOSService = ('http://schemas.dell.com/wbem/wscim/1/cim-schema/2/' 'DCIM_BIOSService') +DCIM_BIOSEnumeration = ('http://schemas.dell.com/wbem/wscim/1/cim-schema/2/' + 'DCIM_BIOSEnumeration') +DCIM_BIOSString = ('http://schemas.dell.com/wbem/wscim/1/cim-schema/2/' + 'DCIM_BIOSString') +DCIM_BIOSInteger = ('http://schemas.dell.com/wbem/wscim/1/cim-schema/2/' + 'DCIM_BIOSInteger') + DCIM_LifecycleJob = ('http://schemas.dell.com/wbem/wscim/1/cim-schema/2/' 'DCIM_LifecycleJob') DCIM_SystemView = ('http://schemas.dell.com/wbem/wscim/1/cim-schema/2/' 'DCIM_SystemView') + +CIM_XmlSchema = 'http://www.w3.org/2001/XMLSchema-instance' + +CIM_WSMAN = 'http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd' diff --git a/ironic/drivers/modules/drac/vendor_passthru.py b/ironic/drivers/modules/drac/vendor_passthru.py new file mode 100644 index 0000000000..0be681f2d8 --- /dev/null +++ b/ironic/drivers/modules/drac/vendor_passthru.py @@ -0,0 +1,118 @@ +# +# 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 VendorPassthruBios Driver +""" + +from ironic.drivers import base +from ironic.drivers.modules.drac import bios +from ironic.drivers.modules.drac import common as drac_common + + +class DracVendorPassthru(base.VendorInterface): + """Interface for DRAC specific BIOS configuration methods.""" + + def get_properties(self): + """Returns the driver_info properties. + + This method returns the driver_info properties for this driver. + + :returns: a dictionary of propery names and their descriptions. + """ + return drac_common.COMMON_PROPERTIES + + def validate(self, task, **kwargs): + """Validates the driver_info of a node. + + This method validates the driver_info associated with the node that is + associated with the task. + + :param task: the ironic task used to identify the node. + :param kwargs: not used. + :raises: InvalidParameterValue if mandatory information is missing on + the node or any driver_info is invalid. + :returns: a dict containing information from driver_info + and default values. + """ + return drac_common.parse_driver_info(task.node) + + @base.passthru(['GET'], async=False) + def get_bios_config(self, task, **kwargs): + """Get BIOS settings. + + This method is used to retrieve the BIOS settings from a node. + + :param task: the ironic task used to identify the node. + :param kwargs: not used. + :raises: DracClientError on an error from pywsman. + :raises: DracOperationFailed when a BIOS setting cannot be parsed. + :returns: a dictionary containing BIOS settings. + """ + return bios.get_config(task.node) + + @base.passthru(['POST'], async=False) + def set_bios_config(self, task, **kwargs): + """Change BIOS settings. + + This method is used to change the BIOS settings on a node. + + :param task: the ironic task used to identify the node. + :param kwargs: a dictionary of {'AttributeName': 'NewValue'} + :raises: DracOperationFailed if any of the attributes cannot be set for + any reason. + :raises: DracClientError on an error from the pywsman library. + :returns: A dictionary containing the commit_needed key with a boolean + value indicating whether commit_config() needs to be called + to make the changes. + """ + return {'commit_needed': bios.set_config(task, **kwargs)} + + @base.passthru(['POST'], async=False) + def commit_bios_config(self, task, **kwargs): + """Commit a BIOS configuration job. + + This method is used to commit a BIOS configuration job. + submitted through set_bios_config(). + + :param task: the ironic task for running the config job. + :param kwargs: not used. + :raises: DracClientError on an error from pywsman library. + :raises: DracPendingConfigJobExists if the job is already created. + :raises: DracOperationFailed if the client received response with an + error message. + :raises: DracUnexpectedReturnValue if the client received a response + with unexpected return value + :returns: A dictionary containing the committing key with no return + value, and the reboot_needed key with a value of True. + """ + bios.commit_config(task) + return {'committing': None, 'reboot_needed': True} + + @base.passthru(['DELETE'], async=False) + def abandon_bios_config(self, task, **kwargs): + """Abandon a BIOS configuration job. + + This method is used to abandon a BIOS configuration job previously + submitted through set_bios_config(). + + :param task: the ironic task for abandoning the changes. + :param kwargs: not used. + :raises: DracClientError on an error from pywsman library. + :raises: DracOperationFailed on error reported back by DRAC. + :raises: DracUnexpectedReturnValue if the drac did not report success. + :returns: A dictionary containing the abandoned key with no return + value. + """ + bios.abandon_config(task) + return {'abandoned': None} diff --git a/ironic/tests/drivers/drac/bios_wsman_mock.py b/ironic/tests/drivers/drac/bios_wsman_mock.py new file mode 100644 index 0000000000..245d27c016 --- /dev/null +++ b/ironic/tests/drivers/drac/bios_wsman_mock.py @@ -0,0 +1,273 @@ +# +# Copyright 2015 Dell, 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. + +""" +Test class for DRAC BIOS interface +""" + +from ironic.drivers.modules.drac import resource_uris + +Enumerations = { + resource_uris.DCIM_BIOSEnumeration: { + 'XML': """ + + http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous + + +http://schemas.xmlsoap.org/ws/2004/09/enumeration/EnumerateResponse + uuid:1f5cd907-0e6f-1e6f-8002-4f266e3acab8 + uuid:219ca357-0e6f-1e6f-a828-f0e4fb722ab8 + + + + + + MemTest + Disabled + + 310 + BIOS.Setup.1-1 + Memory Settings + MemSettings + BIOS.Setup.1-1:MemTest + false + + Enabled + Disabled + + + C States + ProcCStates + Disabled + 1706 + BIOS.Setup.1-1 + System Profile Settings + SysProfileSettings + BIOS.Setup.1-1:ProcCStates + true + + Enabled + Disabled + + + + + """, + 'Dict': { + 'MemTest': { + 'name': 'MemTest', + 'current_value': 'Disabled', + 'pending_value': None, + 'read_only': False, + 'possible_values': ['Disabled', 'Enabled']}, + 'ProcCStates': { + 'name': 'ProcCStates', + 'current_value': 'Disabled', + 'pending_value': None, + 'read_only': True, + 'possible_values': ['Disabled', 'Enabled']}}}, + resource_uris.DCIM_BIOSString: { + 'XML': """ + + http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous + + +http://schemas.xmlsoap.org/ws/2004/09/enumeration/EnumerateResponse + + uuid:1f877bcb-0e6f-1e6f-8004-4f266e3acab8 + uuid:21bea321-0e6f-1e6f-a82b-f0e4fb722ab8 + + + + + + SystemModelName + PowerEdge R630 + + 201 + BIOS.Setup.1-1 + System Information + SysInformation + BIOS.Setup.1-1:SystemModelName + true + 40 + 0 + + + + + SystemModelName2 + PowerEdge R630 + + 201 + BIOS.Setup.1-1 + System Information + SysInformation + BIOS.Setup.1-1:SystemModelName2 + true + 40 + 0 + + + + Asset Tag + AssetTag + + + 1903 + BIOS.Setup.1-1 + Miscellaneous Settings + MiscSettings + BIOS.Setup.1-1:AssetTag + false + 63 + 0 + + ^[ -~]{0,63}$ + + + + + + + """, + 'Dict': { + 'SystemModelName': { + 'name': 'SystemModelName', + 'current_value': 'PowerEdge R630', + 'pending_value': None, + 'read_only': True, + 'min_length': 0, + 'max_length': 40, + 'pcre_regex': None}, + 'SystemModelName2': { + 'name': 'SystemModelName2', + 'current_value': 'PowerEdge R630', + 'pending_value': None, + 'read_only': True, + 'min_length': 0, + 'max_length': 40, + 'pcre_regex': None}, + 'AssetTag': { + 'name': 'AssetTag', + 'current_value': None, + 'pending_value': None, + 'read_only': False, + 'min_length': 0, + 'max_length': 63, + 'pcre_regex': '^[ -~]{0,63}$'}}}, + resource_uris.DCIM_BIOSInteger: { + 'XML': """ + + +http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous + +http://schemas.xmlsoap.org/ws/2004/09/enumeration/EnumerateResponse + uuid:1fa60792-0e6f-1e6f-8005-4f266e3acab8 + uuid:21ccf01d-0e6f-1e6f-a82d-f0e4fb722ab8 + + + + + + Proc1NumCores + 8 + + 439 + BIOS.Setup.1-1 + Processor Settings + ProcSettings + BIOS.Setup.1-1:Proc1NumCores + true + 0 + + 65535 + + + AcPwrRcvryUserDelay + 60 + 1825 + BIOS.Setup.1-1 + System Security + SysSecurity + BIOS.Setup.1-1:AcPwrRcvryUserDelay + false + 60 + + 240 + + + + + + + """, + 'Dict': { + 'Proc1NumCores': { + 'name': 'Proc1NumCores', + 'current_value': 8, + 'pending_value': None, + 'read_only': True, + 'lower_bound': 0, + 'upper_bound': 65535}, + 'AcPwrRcvryUserDelay': { + 'name': 'AcPwrRcvryUserDelay', + 'current_value': 60, + 'pending_value': None, + 'read_only': False, + 'lower_bound': 60, + 'upper_bound': 240}}}} + +Invoke_Commit = """ + + +http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous + +http://schemas.dell.com/wbem/wscim/1/cim-schema/2/DCIM_BIOSService/SetAttributesResponse + uuid:42baa476-0ee9-1ee9-8020-4f266e3acab8 + uuid:fadae2f8-0eea-1eea-9626-76a8f1d9bed4 + + + + The command was successful. + BIOS001 + Yes + 0 + Set PendingValue + + +""" diff --git a/ironic/tests/drivers/drac/test_bios.py b/ironic/tests/drivers/drac/test_bios.py new file mode 100644 index 0000000000..4b063c60a2 --- /dev/null +++ b/ironic/tests/drivers/drac/test_bios.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2015 Dell, 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. + +""" +Test class for DRAC BIOS interface +""" + +import mock + +from ironic.common import exception +from ironic.conductor import task_manager +from ironic.drivers.modules.drac import bios +from ironic.drivers.modules.drac import client as drac_client +from ironic.drivers.modules.drac import management as drac_mgmt +from ironic.drivers.modules.drac 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 bios_wsman_mock +from ironic.tests.drivers.drac import utils as test_utils +from ironic.tests.objects import utils as obj_utils +from six.moves.urllib.parse import unquote + +FAKE_DRAC = db_utils.get_test_drac_info() + + +def _base_config(responses=[]): + for resource in [resource_uris.DCIM_BIOSEnumeration, + resource_uris.DCIM_BIOSString, + resource_uris.DCIM_BIOSInteger]: + xml_root = test_utils.mock_wsman_root( + bios_wsman_mock.Enumerations[resource]['XML']) + responses.append(xml_root) + return responses + + +def _set_config(responses=[]): + ccj_xml = test_utils.build_soap_xml([{'DCIM_LifecycleJob': + {'Name': 'fake'}}], + resource_uris.DCIM_LifecycleJob) + responses.append(test_utils.mock_wsman_root(ccj_xml)) + return _base_config(responses) + + +def _mock_pywsman_responses(client, responses): + mpw = client.Client.return_value + mpw.enumerate.side_effect = responses + return mpw + + +@mock.patch.object(drac_client, 'pywsman') +class DracBiosTestCase(db_base.DbTestCase): + + def setUp(self): + super(DracBiosTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_drac') + self.node = obj_utils.create_test_node(self.context, + driver='fake_drac', + driver_info=FAKE_DRAC) + + def test_get_config(self, client): + _mock_pywsman_responses(client, _base_config()) + expected = {} + for resource in [resource_uris.DCIM_BIOSEnumeration, + resource_uris.DCIM_BIOSString, + resource_uris.DCIM_BIOSInteger]: + expected.update(bios_wsman_mock.Enumerations[resource]['Dict']) + result = bios.get_config(self.node) + self.assertEqual(expected, result) + + def test_set_config_empty(self, client): + _mock_pywsman_responses(client, _set_config()) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + res = bios.set_config(task) + self.assertFalse(res) + + def test_set_config_nochange(self, client): + _mock_pywsman_responses(client, _set_config()) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node = self.node + res = bios.set_config(task, + MemTest='Disabled', + ProcCStates='Disabled', + SystemModelName='PowerEdge R630', + AssetTag=None, + Proc1NumCores=8, + AcPwrRcvryUserDelay=60) + self.assertFalse(res) + + def test_set_config_ro(self, client): + _mock_pywsman_responses(client, _set_config()) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node = self.node + self.assertRaises(exception.DracOperationFailed, + bios.set_config, task, + ProcCStates="Enabled") + + def test_set_config_enum_invalid(self, client): + _mock_pywsman_responses(client, _set_config()) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node = self.node + self.assertRaises(exception.DracOperationFailed, + bios.set_config, task, + MemTest="Never") + + def test_set_config_string_toolong(self, client): + _mock_pywsman_responses(client, _set_config()) + tag = ('Never have I seen such a silly long asset tag! ' + 'It is really rather ridiculous, don\'t you think?') + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node = self.node + self.assertRaises(exception.DracOperationFailed, + bios.set_config, task, + AssetTag=tag) + + def test_set_config_string_nomatch(self, client): + _mock_pywsman_responses(client, _set_config()) + tag = unquote('%80') + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node = self.node + self.assertRaises(exception.DracOperationFailed, + bios.set_config, task, + AssetTag=tag) + + def test_set_config_integer_toosmall(self, client): + _mock_pywsman_responses(client, _set_config()) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node = self.node + self.assertRaises(exception.DracOperationFailed, + bios.set_config, task, + AcPwrRcvryUserDelay=0) + + def test_set_config_integer_toobig(self, client): + _mock_pywsman_responses(client, _set_config()) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node = self.node + self.assertRaises(exception.DracOperationFailed, + bios.set_config, task, + AcPwrRcvryUserDelay=600) + + def test_set_config_needreboot(self, client): + mock_pywsman = _mock_pywsman_responses(client, _set_config()) + invoke_xml = test_utils.mock_wsman_root( + bios_wsman_mock.Invoke_Commit) + # TODO(victor-lowther) This needs more work. + # Specifically, we will need to verify that + # invoke was handed the XML blob we expected. + mock_pywsman.invoke.return_value = invoke_xml + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node = self.node + res = bios.set_config(task, + AssetTag="An Asset Tag", + MemTest="Enabled") + self.assertTrue(res) + + @mock.patch.object(drac_mgmt, 'check_for_config_job', + spec_set=True, autospec=True) + @mock.patch.object(drac_mgmt, 'create_config_job', spec_set=True, + autospec=True) + def test_commit_config(self, mock_ccj, mock_cfcj, client): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node = self.node + bios.commit_config(task) + self.assertTrue(mock_cfcj.called) + self.assertTrue(mock_ccj.called) + + @mock.patch.object(drac_client.Client, 'wsman_invoke', spec_set=True, + autospec=True) + def test_abandon_config(self, mock_wi, client): + _mock_pywsman_responses(client, _set_config()) + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node = self.node + bios.abandon_config(task) + self.assertTrue(mock_wi.called) diff --git a/ironic/tests/drivers/drac/test_client.py b/ironic/tests/drivers/drac/test_client.py index 565a551b0a..a7735c45d4 100644 --- a/ironic/tests/drivers/drac/test_client.py +++ b/ironic/tests/drivers/drac/test_client.py @@ -247,7 +247,8 @@ class DracClientTestCase(base.TestCase): method_name = 'method' client = drac_client.Client(**INFO_DICT) self.assertRaises(exception.DracUnexpectedReturnValue, - client.wsman_invoke, self.resource_uri, method_name) + client.wsman_invoke, self.resource_uri, method_name, + {}, {}, drac_client.RET_SUCCESS) mock_options = mock_client_pywsman.ClientOptions.return_value mock_pywsman_client.invoke.assert_called_once_with( diff --git a/ironic/tests/drivers/drac/test_management.py b/ironic/tests/drivers/drac/test_management.py index 930401db9c..9cc7f42c1f 100644 --- a/ironic/tests/drivers/drac/test_management.py +++ b/ironic/tests/drivers/drac/test_management.py @@ -94,7 +94,7 @@ class DracManagementInternalMethodsTestCase(db_base.DbTestCase): mock_pywsman = mock_client_pywsman.Client.return_value mock_pywsman.enumerate.return_value = mock_xml - result = drac_mgmt._check_for_config_job(self.node) + result = drac_mgmt.check_for_config_job(self.node) self.assertIsNone(result) mock_pywsman.enumerate.assert_called_once_with( @@ -112,7 +112,7 @@ class DracManagementInternalMethodsTestCase(db_base.DbTestCase): mock_pywsman.enumerate.return_value = mock_xml self.assertRaises(exception.DracPendingConfigJobExists, - drac_mgmt._check_for_config_job, self.node) + drac_mgmt.check_for_config_job, self.node) mock_pywsman.enumerate.assert_called_once_with( mock.ANY, mock.ANY, resource_uris.DCIM_LifecycleJob) @@ -130,13 +130,13 @@ class DracManagementInternalMethodsTestCase(db_base.DbTestCase): mock_pywsman.enumerate.return_value = mock_xml try: - drac_mgmt._check_for_config_job(self.node) + drac_mgmt.check_for_config_job(self.node) except (exception.DracClientError, exception.DracPendingConfigJobExists): self.fail("Failed to detect completed job due to " "\"{}\" job status".format(job_status)) - def test__create_config_job(self, mock_client_pywsman): + def test_create_config_job(self, mock_client_pywsman): result_xml = test_utils.build_soap_xml( [{'ReturnValue': drac_client.RET_CREATED}], resource_uris.DCIM_BIOSService) @@ -145,14 +145,14 @@ class DracManagementInternalMethodsTestCase(db_base.DbTestCase): mock_pywsman = mock_client_pywsman.Client.return_value mock_pywsman.invoke.return_value = mock_xml - result = drac_mgmt._create_config_job(self.node) + result = drac_mgmt.create_config_job(self.node) self.assertIsNone(result) mock_pywsman.invoke.assert_called_once_with( mock.ANY, resource_uris.DCIM_BIOSService, 'CreateTargetedConfigJob', None) - def test__create_config_job_error(self, mock_client_pywsman): + def test_create_config_job_error(self, mock_client_pywsman): result_xml = test_utils.build_soap_xml( [{'ReturnValue': drac_client.RET_ERROR, 'Message': 'E_FAKE'}], @@ -163,7 +163,7 @@ class DracManagementInternalMethodsTestCase(db_base.DbTestCase): mock_pywsman.invoke.return_value = mock_xml self.assertRaises(exception.DracOperationFailed, - drac_mgmt._create_config_job, self.node) + drac_mgmt.create_config_job, self.node) mock_pywsman.invoke.assert_called_once_with( mock.ANY, resource_uris.DCIM_BIOSService, 'CreateTargetedConfigJob', None) @@ -280,9 +280,9 @@ class DracManagementTestCase(db_base.DbTestCase): autospec=True) @mock.patch.object(drac_mgmt, '_get_lifecycle_controller_version', spec_set=True, autospec=True) - @mock.patch.object(drac_mgmt, '_check_for_config_job', spec_set=True, + @mock.patch.object(drac_mgmt, 'check_for_config_job', spec_set=True, autospec=True) - @mock.patch.object(drac_mgmt, '_create_config_job', spec_set=True, + @mock.patch.object(drac_mgmt, 'create_config_job', spec_set=True, autospec=True) def test_set_boot_device(self, mock_ccj, mock_cfcj, mock_glcv, mock_gbd, mock_client_pywsman): @@ -323,9 +323,9 @@ class DracManagementTestCase(db_base.DbTestCase): autospec=True) @mock.patch.object(drac_mgmt, '_get_lifecycle_controller_version', spec_set=True, autospec=True) - @mock.patch.object(drac_mgmt, '_check_for_config_job', spec_set=True, + @mock.patch.object(drac_mgmt, 'check_for_config_job', spec_set=True, autospec=True) - @mock.patch.object(drac_mgmt, '_create_config_job', spec_set=True, + @mock.patch.object(drac_mgmt, 'create_config_job', spec_set=True, autospec=True) def test_set_boot_device_fail(self, mock_ccj, mock_cfcj, mock_glcv, mock_gbd, mock_client_pywsman): @@ -368,7 +368,7 @@ class DracManagementTestCase(db_base.DbTestCase): spec_set=True, autospec=True) @mock.patch.object(drac_client.Client, 'wsman_enumerate', spec_set=True, autospec=True) - @mock.patch.object(drac_mgmt, '_check_for_config_job', spec_set=True, + @mock.patch.object(drac_mgmt, 'check_for_config_job', spec_set=True, autospec=True) def test_set_boot_device_client_error(self, mock_cfcj, mock_we, mock_glcv, mock_gbd, @@ -394,7 +394,7 @@ class DracManagementTestCase(db_base.DbTestCase): autospec=True) @mock.patch.object(drac_mgmt, '_get_lifecycle_controller_version', spec_set=True, autospec=True) - @mock.patch.object(drac_mgmt, '_check_for_config_job', spec_set=True, + @mock.patch.object(drac_mgmt, 'check_for_config_job', spec_set=True, autospec=True) def test_set_boot_device_noop(self, mock_cfcj, mock_glcv, mock_gbd, mock_client_pywsman): @@ -419,9 +419,9 @@ class DracManagementTestCase(db_base.DbTestCase): autospec=True) @mock.patch.object(drac_mgmt, '_get_lifecycle_controller_version', spec_set=True, autospec=True) - @mock.patch.object(drac_mgmt, '_check_for_config_job', spec_set=True, + @mock.patch.object(drac_mgmt, 'check_for_config_job', spec_set=True, autospec=True) - @mock.patch.object(drac_mgmt, '_create_config_job', spec_set=True, + @mock.patch.object(drac_mgmt, 'create_config_job', spec_set=True, autospec=True) def test_set_boot_device_11g(self, mock_ccj, mock_cfcj, mock_glcv, mock_gbd, mock_client_pywsman):