diff --git a/releasenotes/notes/add-bios-bf69ac56c4ae8f50.yaml b/releasenotes/notes/add-bios-bf69ac56c4ae8f50.yaml new file mode 100644 index 00000000..d82ef150 --- /dev/null +++ b/releasenotes/notes/add-bios-bf69ac56c4ae8f50.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Adds support for the BIOS resource to the library. diff --git a/sushy/resources/common.py b/sushy/resources/common.py index 839c97d2..17cfe417 100644 --- a/sushy/resources/common.py +++ b/sushy/resources/common.py @@ -20,3 +20,10 @@ class ActionField(base.CompositeField): class ResetActionField(ActionField): allowed_values = base.Field('ResetType@Redfish.AllowableValues', adapter=list) + + +class IdRefField(base.CompositeField): + """Reference to the resource for updating settings""" + + resource_uri = base.Field('@odata.id') + """The unique identifier for a resource""" diff --git a/sushy/resources/settings.py b/sushy/resources/settings.py new file mode 100644 index 00000000..29254fdf --- /dev/null +++ b/sushy/resources/settings.py @@ -0,0 +1,101 @@ +# 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. + +# This is referred from Redfish standard schema. +# http://redfish.dmtf.org/schemas/v1/Settings.v1_0_0.json + + +from sushy.resources import base +from sushy.resources import common + + +class MessageListField(base.ListField): + """List of messages with details of settings update status""" + + message_id = base.Field('MessageId', required=True) + """The key for this message which can be used + to look up the message in a message registry + """ + + message = base.Field('Message') + """Human readable message, if provided""" + + severity = base.Field('Severity') + """Severity of the error""" + + resolution = base.Field('Resolution') + """Used to provide suggestions on how to resolve + the situation that caused the error + """ + + _related_properties = base.Field('RelatedProperties') + """List of properties described by the message""" + + message_args = base.Field('MessageArgs') + """List of message substitution arguments for the message + referenced by `message_id` from the message registry + """ + + +class SettingsField(base.CompositeField): + """The settings of a resource + + Represents the future state and configuration of the resource. The + field is added to resources that support future state and + configuration. + + This field includes several properties to help clients monitor when + the resource is consumed by the service and determine the results of + applying the values, which may or may not have been successful. + """ + + def __init__(self): + super(SettingsField, self).__init__(path="@Redfish.Settings") + + time = base.Field('Time') + """Indicates the time the settings were applied to the server""" + + _etag = base.Field('ETag') + """The ETag of the resource to which the settings were applied, + after the application + """ + + _settings_object_idref = common.IdRefField("SettingsObject") + """Reference to the resource the client may PUT/PATCH in order + to change this resource + """ + + messages = MessageListField("Messages") + """Represents the results of the last time the values of the Settings + resource were applied to the server""" + + def commit(self, connector, value, etag=None): + """Commits new settings values + + The new values will be applied when the system or a service + restarts. + + :param connector: A Connector instance + :param value: Value representing JSON whose structure is specific + to each resource and the caller must format it correctly + :param etag: Optional ETag of resource version to update. If + this ETag is provided and it does not match on server, then + the new values will not be committed + """ + + connector.patch(self.resource_uri, + data=value, + headers={'If-Match': etag} if etag else None) + + @property + def resource_uri(self): + return self._settings_object_idref.resource_uri diff --git a/sushy/resources/system/bios.py b/sushy/resources/system/bios.py new file mode 100644 index 00000000..3ccfeb97 --- /dev/null +++ b/sushy/resources/system/bios.py @@ -0,0 +1,162 @@ +# 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. + +# This is referred from Redfish standard schema. +# https://redfish.dmtf.org/schemas/Bios.v1_0_3.json + +import logging + +from sushy import exceptions +from sushy.resources import base +from sushy.resources import common +from sushy.resources import settings + +LOG = logging.getLogger(__name__) + + +class ActionsField(base.CompositeField): + change_password = common.ActionField('#Bios.ChangePassword') + reset_bios = common.ActionField('#Bios.ResetBios') + + +class Bios(base.ResourceBase): + + identity = base.Field('Id', required=True) + """The Bios resource identity string""" + + name = base.Field('Name') + """The name of the resource""" + + description = base.Field('Description') + """Human-readable description of the BIOS resource""" + + attribute_registry = base.Field('AttributeRegistry') + """The Resource ID of the Attribute Registry + for the BIOS Attributes resource + """ + + _settings = settings.SettingsField() + """Results of last BIOS attribute update""" + + attributes = base.Field('Attributes') + """Vendor-specific key-value dict of effective BIOS attributes + + Attributes cannot be updated directly. + To update use :py:func:`~set_attribute` or :py:func:`~set_attributes` + """ + + _actions = ActionsField('Actions') + + _etag = base.Field('@odata.etag') + + _pending_settings_resource = None + + @property + def pending_attributes(self): + """Pending BIOS attributes + + BIOS attributes that have been comitted to the system, + but for them to take effect system restart is necessary + """ + + if not self._pending_settings_resource: + self._pending_settings_resource = Bios( + self._conn, + self._settings.resource_uri, + redfish_version=self.redfish_version) + self._pending_settings_resource.refresh(force=False) + return self._pending_settings_resource.attributes + + def set_attribute(self, key, value): + """Update an attribute + + Attribute update is not immediate but requires system restart. + Committed attributes can be checked at :py:attr:`~pending_attributes` + property + + :param key: Attribute name + :param value: Attribute value + """ + self.set_attributes({key: value}) + + def set_attributes(self, value): + """Update many attributes at once + + Attribute update is not immediate but requires system restart. + Committed attributes can be checked at :py:attr:`~pending_attributes` + property + + :param value: Key-value pairs for attribute name and value + """ + self._settings.commit(self._conn, + {'Attributes': value}, + self._etag) + if self._pending_settings_resource: + self._pending_settings_resource.invalidate() + + def _get_reset_bios_action_element(self): + actions = self._actions + + if not actions: + raise exceptions.MissingAttributeError(attribute="Actions", + resource=self._path) + + reset_bios_action = actions.reset_bios + + if not reset_bios_action: + raise exceptions.MissingActionError(action='#Bios.ResetBios', + resource=self._path) + return reset_bios_action + + def _get_change_password_element(self): + actions = self._actions + + if not actions: + raise exceptions.MissingAttributeError(attribute="Actions", + resource=self._path) + + change_password_action = actions.change_password + + if not change_password_action: + raise exceptions.MissingActionError(action='#Bios.ChangePassword', + resource=self._path) + return change_password_action + + def reset_bios(self): + """Reset the BIOS attributes to default""" + + target_uri = self._get_reset_bios_action_element().target_uri + + LOG.debug('Resetting BIOS attributes %s ...', self.identity) + self._conn.post(target_uri) + LOG.info('BIOS attributes %s is being reset', self.identity) + + def change_password(self, new_password, old_password, password_name): + """Change BIOS password""" + + target_uri = self._get_change_password_element().target_uri + + LOG.debug('Changing BIOS password %s ...', self.identity) + self._conn.post(target_uri, data={'NewPassword': new_password, + 'OldPassword': old_password, + 'PasswordName': password_name}) + LOG.info('BIOS password %s is being changed', self.identity) + + def _do_refresh(self, force=False): + """Do custom resource specific refresh activities + + On refresh, all sub-resources are marked as stale, i.e. + greedy-refresh not done for them unless forced by ``force`` + argument. + """ + if self._pending_settings_resource is not None: + self._pending_settings_resource.invalidate(force) diff --git a/sushy/resources/system/system.py b/sushy/resources/system/system.py index b2a53b23..b976da24 100644 --- a/sushy/resources/system/system.py +++ b/sushy/resources/system/system.py @@ -18,6 +18,7 @@ import logging from sushy import exceptions from sushy.resources import base from sushy.resources import common +from sushy.resources.system import bios from sushy.resources.system import constants as sys_cons from sushy.resources.system import ethernet_interface from sushy.resources.system import mappings as sys_maps @@ -130,6 +131,8 @@ class System(base.ResourceBase): _ethernet_interfaces = None + _bios = None + def __init__(self, connector, identity, redfish_version=None): """A class representing a ComputerSystem @@ -289,6 +292,23 @@ class System(base.ResourceBase): self._ethernet_interfaces.refresh(force=False) return self._ethernet_interfaces + @property + def bios(self): + """Property to reference `Bios` instance + + It is set once when the first time it is queried. On refresh, + this property is marked as stale (greedy-refresh not done). + Here the actual refresh of the sub-resource happens, if stale. + """ + if self._bios is None: + self._bios = bios.Bios( + self._conn, + utils.get_sub_resource_path_by(self, 'Bios'), + redfish_version=self.redfish_version) + + self._bios.refresh(force=False) + return self._bios + def _do_refresh(self, force=False): """Do custom resource specific refresh activities @@ -300,6 +320,8 @@ class System(base.ResourceBase): self._processors.invalidate(force) if self._ethernet_interfaces is not None: self._ethernet_interfaces.invalidate(force) + if self._bios is not None: + self._bios.invalidate(force) class SystemCollection(base.ResourceCollectionBase): diff --git a/sushy/tests/unit/json_samples/bios.json b/sushy/tests/unit/json_samples/bios.json new file mode 100644 index 00000000..7625af9c --- /dev/null +++ b/sushy/tests/unit/json_samples/bios.json @@ -0,0 +1,45 @@ +{ + "@odata.type": "#Bios.v1_0_0.Bios", + "Id": "BIOS", + "Name": "BIOS Configuration Current Settings", + "AttributeRegistry": "BiosAttributeRegistryP89.v1_0_0", + "Attributes": { + "AdminPhone": "", + "BootMode": "Uefi", + "EmbeddedSata": "Raid", + "NicBoot1": "NetworkBoot", + "NicBoot2": "Disabled", + "PowerProfile": "MaxPerf", + "ProcCoreDisable": 0, + "ProcHyperthreading": "Enabled", + "ProcTurboMode": "Enabled", + "UsbControl": "UsbEnabled" + }, + "@Redfish.Settings": { + "@odata.type": "#Settings.v1_0_0.Settings", + "ETag": "9234ac83b9700123cc32", + "Messages": [ + { + "MessageId": "Base.1.0.SettingsFailed", + "RelatedProperties": [ + "#/Attributes/ProcTurboMode" + ] + } + ], + "SettingsObject": { + "@odata.id": "/redfish/v1/Systems/437XR1138R2/BIOS/Settings" + }, + "Time": "2016-03-07T14:44.30-05:00" + }, + "Actions": { + "#Bios.ResetBios": { + "target": "/redfish/v1/Systems/437XR1138R2/BIOS/Actions/Bios.ResetBios" + }, + "#Bios.ChangePassword": { + "target": "/redfish/v1/Systems/437XR1138R2/BIOS/Actions/Bios.ChangePassword" + } + }, + "@odata.etag": "123", + "@odata.context": "/redfish/v1/$metadata#Bios.Bios", + "@odata.id": "/redfish/v1/Systems/437XR1138R2/BIOS" +} diff --git a/sushy/tests/unit/json_samples/bios_settings.json b/sushy/tests/unit/json_samples/bios_settings.json new file mode 100644 index 00000000..b7c77720 --- /dev/null +++ b/sushy/tests/unit/json_samples/bios_settings.json @@ -0,0 +1,21 @@ +{ + "@odata.type": "#Bios.v1_0_0.Bios", + "Id": "Settings", + "Name": "BIOS Configuration Pending Settings", + "AttributeRegistry": "BiosAttributeRegistryP89.v1_0_0", + "Attributes": { + "AdminPhone": "(404) 555-1212", + "BootMode": "Uefi", + "EmbeddedSata": "Ahci", + "NicBoot1": "NetworkBoot", + "NicBoot2": "NetworkBoot", + "PowerProfile": "MaxPerf", + "ProcCoreDisable": 0, + "ProcHyperthreading": "Enabled", + "ProcTurboMode": "Disabled", + "UsbControl": "UsbEnabled" + }, + "@odata.context": "/redfish/v1/$metadata#Bios.Bios", + "@odata.id": "/redfish/v1/Systems/437XR1138R2/BIOS/Settings", + "@Redfish.Copyright": "Copyright 2014-2016 Distributed Management Task Force, Inc. (DMTF). For the full DMTF copyright policy, see http://www.dmtf.org/about/policies/copyright." +} diff --git a/sushy/tests/unit/json_samples/settings.json b/sushy/tests/unit/json_samples/settings.json new file mode 100644 index 00000000..8119885f --- /dev/null +++ b/sushy/tests/unit/json_samples/settings.json @@ -0,0 +1,22 @@ +{ + "@Redfish.Settings": { + "@odata.type": "#Settings.v1_0_0.Settings", + "ETag": "9234ac83b9700123cc32", + "Messages": [{ + "MessageId": "Base.1.0.SettingsFailed", + "Message": "Settings update failed due to invalid value", + "Severity": "High", + "Resolution": "Fix the value and try again", + "MessageArgs": [ + "arg1" + ], + "RelatedProperties": [ + "#/Attributes/ProcTurboMode" + ] + }], + "SettingsObject": { + "@odata.id": "/redfish/v1/Systems/437XR1138R2/BIOS/Settings" + }, + "Time": "2016-03-07T14:44.30-05:00" + } +} diff --git a/sushy/tests/unit/resources/system/test_bios.py b/sushy/tests/unit/resources/system/test_bios.py new file mode 100644 index 00000000..1497c073 --- /dev/null +++ b/sushy/tests/unit/resources/system/test_bios.py @@ -0,0 +1,154 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json + +import mock + +from sushy import exceptions +from sushy.resources.system import bios +from sushy.tests.unit import base + + +class BiosTestCase(base.TestCase): + + def setUp(self): + super(BiosTestCase, self).setUp() + self.conn = mock.Mock() + with open('sushy/tests/unit/json_samples/bios.json', 'r') as f: + bios_json = json.loads(f.read()) + with open('sushy/tests/unit/json_samples/bios_settings.json', + 'r') as f: + bios_settings_json = json.loads(f.read()) + + self.conn.get.return_value.json.side_effect = [ + bios_json, + bios_settings_json, + bios_settings_json] + + self.sys_bios = bios.Bios( + self.conn, '/redfish/v1/Systems/437XR1138R2/BIOS', + redfish_version='1.0.2') + + def test__parse_attributes(self): + self.sys_bios._parse_attributes() + self.assertEqual('1.0.2', self.sys_bios.redfish_version) + self.assertEqual('BIOS', self.sys_bios.identity) + self.assertEqual('BIOS Configuration Current Settings', + self.sys_bios.name) + self.assertIsNone(self.sys_bios.description) + self.assertEqual('123', self.sys_bios._etag) + self.assertEqual('BiosAttributeRegistryP89.v1_0_0', + self.sys_bios.attribute_registry) + self.assertEqual('', self.sys_bios.attributes['AdminPhone']) + self.assertEqual('Uefi', self.sys_bios.attributes['BootMode']) + self.assertEqual(0, self.sys_bios.attributes['ProcCoreDisable']) + # testing here if settings subfield parsed by checking ETag, + # other settings fields tested in specific settings test + self.assertEqual('9234ac83b9700123cc32', + self.sys_bios._settings._etag) + self.assertEqual('(404) 555-1212', + self.sys_bios.pending_attributes['AdminPhone']) + + def test_set_attribute(self): + self.sys_bios.set_attribute('ProcTurboMode', 'Disabled') + self.sys_bios._conn.patch.assert_called_once_with( + '/redfish/v1/Systems/437XR1138R2/BIOS/Settings', + data={'Attributes': {'ProcTurboMode': 'Disabled'}}, + headers={'If-Match': '123'}) + + def test_set_attribute_on_refresh(self): + # make it to instantiate pending attributes + self.sys_bios.pending_attributes + self.sys_bios.set_attribute('ProcTurboMode', 'Disabled') + self.assertTrue(self.sys_bios._pending_settings_resource._is_stale) + # make it to refresh pending attributes on next retrieval + self.sys_bios.pending_attributes + self.assertFalse(self.sys_bios._pending_settings_resource._is_stale) + + def test_set_attributes(self): + self.sys_bios.set_attributes({'ProcTurboMode': 'Disabled', + 'UsbControl': 'UsbDisabled'}) + self.sys_bios._conn.patch.assert_called_once_with( + '/redfish/v1/Systems/437XR1138R2/BIOS/Settings', + data={'Attributes': {'ProcTurboMode': 'Disabled', + 'UsbControl': 'UsbDisabled'}}, + headers={'If-Match': '123'}) + + def test_set_attributes_on_refresh(self): + # make it to instantiate pending attributes + self.sys_bios.pending_attributes + self.sys_bios.set_attributes({'ProcTurboMode': 'Disabled', + 'UsbControl': 'UsbDisabled'}) + self.assertTrue(self.sys_bios._pending_settings_resource._is_stale) + # make it to refresh pending attributes on next retrieval + self.sys_bios.pending_attributes + self.assertFalse(self.sys_bios._pending_settings_resource._is_stale) + + def test__get_reset_bios_action_element(self): + value = self.sys_bios._get_reset_bios_action_element() + self.assertEqual('/redfish/v1/Systems/437XR1138R2/BIOS/Actions/' + 'Bios.ResetBios', + value.target_uri) + + def test_reset_bios_missing_action(self): + self.sys_bios._actions.reset_bios = None + self.assertRaisesRegex( + exceptions.MissingActionError, '#Bios.ResetBios', + self.sys_bios.reset_bios) + + def test__parse_attributes_missing_reset_bios_target(self): + self.sys_bios.json['Actions']['#Bios.ResetBios'].pop( + 'target') + self.assertRaisesRegex( + exceptions.MissingAttributeError, + 'attribute Actions/#Bios.ResetBios/target', + self.sys_bios._parse_attributes) + + def test_reset_bios(self): + self.sys_bios.reset_bios() + self.sys_bios._conn.post.assert_called_once_with( + '/redfish/v1/Systems/437XR1138R2/BIOS/Actions/Bios.ResetBios') + + def test__get_change_password_element(self): + value = self.sys_bios._get_change_password_element() + self.assertEqual("/redfish/v1/Systems/437XR1138R2/BIOS/Actions/" + "Bios.ChangePassword", + value.target_uri) + + def test_change_password_missing_action(self): + self.sys_bios._actions.change_password = None + self.assertRaisesRegex( + exceptions.MissingActionError, '#Bios.ChangePassword', + self.sys_bios.change_password, 'newpassword', + 'oldpassword', + 'adminpassword') + + def test__parse_attributes_missing_change_password_target(self): + self.sys_bios.json['Actions']['#Bios.ChangePassword'].pop( + 'target') + self.assertRaisesRegex( + exceptions.MissingAttributeError, + 'attribute Actions/#Bios.ChangePassword/target', + self.sys_bios._parse_attributes) + + def test_change_password(self): + self.sys_bios.change_password('newpassword', + 'oldpassword', + 'adminpassword') + self.sys_bios._conn.post.assert_called_once_with( + '/redfish/v1/Systems/437XR1138R2/BIOS/Actions/Bios.ChangePassword', + data={'OldPassword': 'oldpassword', + 'NewPassword': 'newpassword', + 'PasswordName': 'adminpassword'}) diff --git a/sushy/tests/unit/resources/system/test_system.py b/sushy/tests/unit/resources/system/test_system.py index 1ec4faa3..774e60ca 100644 --- a/sushy/tests/unit/resources/system/test_system.py +++ b/sushy/tests/unit/resources/system/test_system.py @@ -19,6 +19,7 @@ import mock import sushy from sushy import exceptions +from sushy.resources.system import bios from sushy.resources.system import constants as sys_cons from sushy.resources.system import ethernet_interface from sushy.resources.system import mappings as sys_map @@ -66,6 +67,7 @@ class SystemTestCase(base.TestCase): self.assertEqual("OK", self.sys_inst.memory_summary.health) self.assertIsNone(self.sys_inst._processors) self.assertIsNone(self.sys_inst._ethernet_interfaces) + self.assertIsNone(self.sys_inst._bios) def test__parse_attributes_missing_actions(self): self.sys_inst.json.pop('Actions') @@ -379,6 +381,18 @@ class SystemTestCase(base.TestCase): self.assertIsInstance(self.sys_inst._ethernet_interfaces, ethernet_interface.EthernetInterfaceCollection) + def test_bios(self): + self.conn.get.return_value.json.reset_mock() + bios_return_value = None + with open('sushy/tests/unit/json_samples/bios.json', 'r') as f: + bios_return_value = json.loads(f.read()) + self.conn.get.return_value.json.side_effect = [bios_return_value] + + self.assertIsNone(self.sys_inst._bios) + self.assertIsInstance(self.sys_inst.bios, bios.Bios) + self.assertEqual('BIOS Configuration Current Settings', + self.sys_inst.bios.name) + class SystemCollectionTestCase(base.TestCase): diff --git a/sushy/tests/unit/resources/test_settings.py b/sushy/tests/unit/resources/test_settings.py new file mode 100644 index 00000000..fd212c2a --- /dev/null +++ b/sushy/tests/unit/resources/test_settings.py @@ -0,0 +1,74 @@ +# Copyright 2017 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import mock + +from sushy.resources import settings +from sushy.tests.unit import base + + +class SettingsFieldTestCase(base.TestCase): + + def setUp(self): + super(SettingsFieldTestCase, self).setUp() + with open('sushy/tests/unit/json_samples/settings.json', + 'r') as f: + self.json = json.loads(f.read()) + + self.settings = settings.SettingsField() + + def test__load(self): + instance = self.settings._load(self.json, mock.Mock()) + + self.assertEqual('9234ac83b9700123cc32', + instance._etag) + self.assertEqual('2016-03-07T14:44.30-05:00', + instance.time) + self.assertEqual('/redfish/v1/Systems/437XR1138R2/BIOS/Settings', + instance._settings_object_idref.resource_uri) + self.assertEqual('Base.1.0.SettingsFailed', + instance.messages[0].message_id) + self.assertEqual('Settings update failed due to invalid value', + instance.messages[0].message) + self.assertEqual('High', + instance.messages[0].severity) + self.assertEqual('Fix the value and try again', + instance.messages[0].resolution) + self.assertEqual('arg1', + instance.messages[0].message_args[0]) + self.assertEqual('#/Attributes/ProcTurboMode', + instance.messages[0]._related_properties[0]) + self.assertEqual('/redfish/v1/Systems/437XR1138R2/BIOS/Settings', + instance._settings_object_idref.resource_uri) + + def test_commit(self): + conn = mock.Mock() + instance = self.settings._load(self.json, conn) + instance.commit(conn, {'Attributes': {'key': 'value'}}) + conn.patch.assert_called_once_with( + '/redfish/v1/Systems/437XR1138R2/BIOS/Settings', + data={'Attributes': {'key': 'value'}}, headers=None) + + def test_commit_with_etag(self): + conn = mock.Mock() + instance = self.settings._load(self.json, conn) + instance.commit(conn, + {'Attributes': {'key': 'value'}}, + '123') + conn.patch.assert_called_once_with( + '/redfish/v1/Systems/437XR1138R2/BIOS/Settings', + data={'Attributes': {'key': 'value'}}, + headers={'If-Match': '123'})