diff --git a/api-ref/source/baremetal-api-v1-nodes-firmware.inc b/api-ref/source/baremetal-api-v1-nodes-firmware.inc new file mode 100644 index 0000000000..ed17e0b7b2 --- /dev/null +++ b/api-ref/source/baremetal-api-v1-nodes-firmware.inc @@ -0,0 +1,48 @@ +.. -*- rst -*- + +===================== +Node Firmware (nodes) +===================== + +.. versionadded:: 1.84 + +Given a Node identifier (``uuid`` or ``name``), the API exposes the list of +all Firmware Components associated with that Node. + +These endpoints do not allow modification of the Firmware Components; that +should be done by using ``clean steps``. + +List all Firmware Components by Node +==================================== + +.. rest_method:: GET /v1/nodes/{node_ident}/firmware + +Return a list of Firmware Components associated with ``node_ident``. + +Normal response code: 200 + +Error codes: 404 + +Request +------- + +.. rest_parameters:: parameters.yaml + + - node_ident: node_ident + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - firmware: firmware_components + - created_at: created_at + - updated_at: updated_at + - component: firmware_component + - initial_version: firmware_component_initial_version + - current_version: firmware_component_current_version + - last_version_flashed: firmware_component_last_version_flashed + +**Example list of a Node's Firmware Components:** + +.. literalinclude:: samples/node-firmware-components-list-response.json diff --git a/api-ref/source/samples/node-firmware-components-list-response.json b/api-ref/source/samples/node-firmware-components-list-response.json new file mode 100644 index 0000000000..2ed13018e6 --- /dev/null +++ b/api-ref/source/samples/node-firmware-components-list-response.json @@ -0,0 +1,20 @@ +{ + "firmware": [ + { + "created_at": "2016-08-18T22:28:49.653974+00:00", + "updated_at": "2016-08-18T22:28:49.653974+00:00", + "component": "BMC", + "initial_version": "v1.0.0", + "current_version": "v1.2.0", + "last_version_flashed": "v1.2.0" + }, + { + "created_at": "2016-08-18T22:28:49.653974+00:00", + "updated_at": "2016-08-18T22:28:49.653974+00:00", + "component": "BIOS", + "initial_version": "v1.0.0", + "current_version": "v1.1.5", + "last_version_flashed": "v1.1.5" + } + ] +} diff --git a/ironic/api/controllers/v1/driver.py b/ironic/api/controllers/v1/driver.py index f117c327f2..c8cd703876 100644 --- a/ironic/api/controllers/v1/driver.py +++ b/ironic/api/controllers/v1/driver.py @@ -80,6 +80,10 @@ def hide_fields_in_newer_versions(driver): driver.pop('default_bios_interface', None) driver.pop('enabled_bios_interfaces', None) + if not api_utils.allow_firmware_interface(): + driver.pop('default_firmware_interface', None) + driver.pop('enabled_firmware_interfaces', None) + def convert_with_links(name, hosts, detail=False, interface_info=None, fields=None, sanitize=True): diff --git a/ironic/api/controllers/v1/firmware.py b/ironic/api/controllers/v1/firmware.py new file mode 100644 index 0000000000..5968edbb2a --- /dev/null +++ b/ironic/api/controllers/v1/firmware.py @@ -0,0 +1,75 @@ +# Copyright 2023 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. + +from ironic_lib import metrics_utils +from pecan import rest + +from ironic import api +from ironic.api.controllers.v1 import utils as api_utils +from ironic.api import method +from ironic.common import args +from ironic import objects + +METRICS = metrics_utils.get_metrics_logger(__name__) + +_DEFAULT_RETURN_FIELDS = ('component', 'initial_version', 'current_version', + 'last_version_flashed') + + +# NOTE(iurygregory): Keeping same parameters just in case we decide +# to support /v1/nodes//firmware/ +def convert_with_links(rpc_firmware, node_uuid, detail=None, fields=None): + """Build a dict containing a firmware component.""" + + fw_component = api_utils.object_to_dict( + rpc_firmware, + include_uuid=False, + fields=fields, + ) + return fw_component + + +def collection_from_list(node_ident, firmware_components, detail=None, + fields=None): + firmware_list = [] + for fw_cmp in firmware_components: + firmware_list.append(convert_with_links(fw_cmp, node_ident, + detail, fields)) + return {'firmware': firmware_list} + + +class NodeFirmwareController(rest.RestController): + """REST controller for Firmware.""" + + def __init__(self, node_ident=None): + super(NodeFirmwareController, self).__init__() + self.node_ident = node_ident + + @METRICS.timer('NodeFirmwareController.get_all') + @method.expose() + @args.validate(fields=args.string_list, detail=args.boolean) + def get_all(self, detail=None, fields=None): + """List node firmware components.""" + node = api_utils.check_node_policy_and_retrieve( + 'baremetal:node:firmware:get', self.node_ident) + + allow_query = api_utils.allow_firmware_interface + fields = api_utils.get_request_return_fields(fields, detail, + _DEFAULT_RETURN_FIELDS, + allow_query, allow_query) + components = objects.FirmwareComponentList.get_by_node_id( + api.request.context, node.id) + return collection_from_list(self.node_ident, components, + detail, fields) diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 94e1e5d844..ea6fcdca1b 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -32,6 +32,7 @@ from ironic.api.controllers import link from ironic.api.controllers.v1 import allocation from ironic.api.controllers.v1 import bios from ironic.api.controllers.v1 import collection +from ironic.api.controllers.v1 import firmware from ironic.api.controllers.v1 import notification_utils as notify from ironic.api.controllers.v1 import port from ironic.api.controllers.v1 import portgroup @@ -169,6 +170,7 @@ def node_schema(): 'driver': {'type': 'string'}, 'driver_info': {'type': ['object', 'null']}, 'extra': {'type': ['object', 'null']}, + 'firmware_interface': {'type': ['string', 'null']}, 'inspect_interface': {'type': ['string', 'null']}, 'instance_info': {'type': ['object', 'null']}, 'instance_uuid': {'type': ['string', 'null']}, @@ -283,7 +285,8 @@ PATCH_ALLOWED_FIELDS = [ 'shard', 'storage_interface', 'vendor_interface', - 'parent_node' + 'parent_node', + 'firmware_interface' ] TRAITS_SCHEMA = { @@ -1395,6 +1398,7 @@ def _get_fields_for_node_query(fields=None): 'driver_internal_info', 'extra', 'fault', + 'firmware_interface', 'inspection_finished_at', 'inspection_started_at', 'inspect_interface', @@ -2114,6 +2118,7 @@ class NodesController(rest.RestController): 'history': NodeHistoryController, 'inventory': NodeInventoryController, 'children': NodeChildrenController, + 'firmware': firmware.NodeFirmwareController, } @pecan.expose() @@ -2139,7 +2144,9 @@ class NodesController(rest.RestController): or (remainder[0] == 'history' and not api_utils.allow_node_history()) or (remainder[0] == 'inventory' - and not api_utils.allow_node_inventory())): + and not api_utils.allow_node_inventory()) + or (remainder[0] == 'firmware' + and not api_utils.allow_firmware_interface())): pecan.abort(http_client.NOT_FOUND) if remainder[0] == 'traits' and not api_utils.allow_traits(): # NOTE(mgoddard): Returning here will ensure we exhibit the diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 23b9c24a26..269cfa3d26 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -807,7 +807,8 @@ VERSIONED_FIELDS = { 'boot_mode': versions.MINOR_75_NODE_BOOT_MODE, 'secure_boot': versions.MINOR_75_NODE_BOOT_MODE, 'shard': versions.MINOR_82_NODE_SHARD, - 'parent_node': versions.MINOR_83_PARENT_CHILD_NODES + 'parent_node': versions.MINOR_83_PARENT_CHILD_NODES, + 'firmware_interface': versions.MINOR_86_FIRMWARE_INTERFACE } for field in V31_FIELDS: @@ -2006,3 +2007,11 @@ def allow_continue_inspection_endpoint(): """ return (new_continue_inspection_endpoint() or api.request.version.minor == versions.MINOR_1_INITIAL_VERSION) + + +def allow_firmware_interface(): + """Check if we should support firmware interface and endpoints. + + Version 1.84 of the API added support for firmware interface. + """ + return api.request.version.minor >= versions.MINOR_86_FIRMWARE_INTERFACE diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 797de17f7a..d0c81efa5b 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -209,6 +209,7 @@ MINOR_82_NODE_SHARD = 82 MINOR_83_PARENT_CHILD_NODES = 83 MINOR_84_CONTINUE_INSPECTION = 84 MINOR_85_UNHOLD_VERB = 85 +MINOR_86_FIRMWARE_INTERFACE = 86 # When adding another version, update: # - MINOR_MAX_VERSION @@ -216,7 +217,7 @@ MINOR_85_UNHOLD_VERB = 85 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_85_UNHOLD_VERB +MINOR_MAX_VERSION = MINOR_86_FIRMWARE_INTERFACE # String representations of the minor and maximum versions _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/policy.py b/ironic/common/policy.py index ac25de2696..8343afef62 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -1009,6 +1009,15 @@ node_policies = [ 'the API clients.', operations=[{'path': '/nodes/{node_ident}', 'method': 'PATCH'}], ), + policy.DocumentedRuleDefault( + name='baremetal:node:firmware:get', + check_str=SYSTEM_OR_PROJECT_READER, + scope_types=['system', 'project'], + description='Retrieve Node Firmware components information', + operations=[ + {'path': '/nodes/{node_ident}/firmware', 'method': 'GET'} + ], + ), ] deprecated_port_reason = """ diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 8175dc3027..d76bd16f75 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -574,12 +574,12 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.85', + 'api': '1.86', 'rpc': '1.56', 'objects': { 'Allocation': ['1.1'], 'BIOSSetting': ['1.1'], - 'Node': ['1.38', '1.37'], + 'Node': ['1.39', '1.38', '1.37'], 'NodeHistory': ['1.0'], 'NodeInventory': ['1.0'], 'Conductor': ['1.3'], diff --git a/ironic/conf/default.py b/ironic/conf/default.py index 5b40c1f311..2f6c0f1ce0 100644 --- a/ironic/conf/default.py +++ b/ironic/conf/default.py @@ -115,6 +115,11 @@ driver_opts = [ help=_ENABLED_IFACE_HELP.format('deploy')), cfg.StrOpt('default_deploy_interface', help=_DEFAULT_IFACE_HELP.format('deploy')), + cfg.ListOpt('enabled_firmware_interfaces', + default=['no-firmware'], + help=_ENABLED_IFACE_HELP.format('firmware')), + cfg.StrOpt('default_firmware_interface', + help=_DEFAULT_IFACE_HELP.format('firmware')), cfg.ListOpt('enabled_inspect_interfaces', default=['no-inspect', 'redfish'], help=_ENABLED_IFACE_HELP.format('inspect')), diff --git a/ironic/conf/fake.py b/ironic/conf/fake.py index 8f6d75ee3e..dd67a683c3 100644 --- a/ironic/conf/fake.py +++ b/ironic/conf/fake.py @@ -78,6 +78,12 @@ opts = [ 'rescue driver. Two comma-delimited values will ' 'result in a delay with a triangular random ' 'distribution, weighted on the first value.')), + cfg.StrOpt('firmware_delay', + default='0', + help=_('Delay in seconds for operations with the fake ' + 'firmware driver. Two comma-delimited values will ' + 'result in a delay with a triangular random ' + 'distribution, weighted on the first value.')), ] diff --git a/ironic/drivers/base.py b/ironic/drivers/base.py index d89ce249ec..1e0cd49719 100644 --- a/ironic/drivers/base.py +++ b/ironic/drivers/base.py @@ -105,6 +105,12 @@ class BareDriver(object): A reference to an instance of :class:DeployInterface. """ + firmware = None + """`Standard` attribute for inspection related features. + + A reference to an instance of :class:FirmwareInterface. + """ + inspect = None """`Standard` attribute for inspection related features. @@ -161,7 +167,8 @@ class BareDriver(object): @property def optional_interfaces(self): """Interfaces that can be no-op.""" - return ['bios', 'console', 'inspect', 'raid', 'rescue', 'storage'] + return ['bios', 'console', 'firmware', 'inspect', 'raid', 'rescue', + 'storage'] @property def all_interfaces(self): @@ -1736,6 +1743,55 @@ class StorageInterface(BaseInterface, metaclass=abc.ABCMeta): """ +def cache_firmware_components(func): + """A decorator to cache firmware components after running the function. + + :param func: Function or method to wrap. + """ + @functools.wraps(func) + def wrapped(self, task, *args, **kwargs): + result = func(self, task, *args, **kwargs) + self.cache_firmware_components(task) + return result + return wrapped + + +class FirmwareInterface(BaseInterface): + """Base class for firmware interface""" + + interface_type = 'firmware' + + @abc.abstractmethod + def update(self, task, settings): + """Update the Firmware on the given using the settings for components. + + :param task: a TaskManager instance. + :param settings: a list of dictionaries, each dictionary contains the + component name and the url that will be used to update the + firmware. + :raises: UnsupportedDriverExtension, if the node's driver doesn't + support update via the interface. + :raises: InvalidParameterValue, if validation of the settings fails. + :raises: MissingParamterValue, if some required parameters are + missing. + :returns: states.CLEANWAIT if Firmware update with the settings is in + progress asynchronously of None if it is complete. + """ + + @abc.abstractmethod + def cache_firmware_components(self, task): + """Store or update Firmware Components on the given node. + + This method stores Firmware Components to the firmware_information + table during 'cleaning' operation. It will also update the timestamp + of each Firmware Component. + + :param task: a TaskManager instance. + :raises: UnsupportedDriverExtension, if the node's driver doesn't + support getting Firmware Components from bare metal. + """ + + def _validate_argsinfo(argsinfo): """Validate args info. diff --git a/ironic/drivers/fake_hardware.py b/ironic/drivers/fake_hardware.py index 92fe8288c4..c370bf3e9c 100644 --- a/ironic/drivers/fake_hardware.py +++ b/ironic/drivers/fake_hardware.py @@ -86,3 +86,8 @@ class FakeHardware(generic.GenericHardware): return [ fake.FakeVendorB, fake.FakeVendorA ] + super().supported_vendor_interfaces + + @property + def supported_firmware_interfaces(self): + """List of classes of supported bios interfaces.""" + return [fake.FakeFirmware] + super().supported_firmware_interfaces diff --git a/ironic/drivers/generic.py b/ironic/drivers/generic.py index 787915e088..0136ca86b2 100644 --- a/ironic/drivers/generic.py +++ b/ironic/drivers/generic.py @@ -86,6 +86,11 @@ class GenericHardware(hardware_type.AbstractHardwareType): return [noop_storage.NoopStorage, cinder.CinderStorage, external_storage.ExternalStorage] + @property + def supported_firmware_interfaces(self): + """List of supported firmware interfaces.""" + return [noop.NoFirmware] + class ManualManagementHardware(GenericHardware): """Hardware type that uses manual power and boot management. diff --git a/ironic/drivers/hardware_type.py b/ironic/drivers/hardware_type.py index df5f437825..df1b064c49 100644 --- a/ironic/drivers/hardware_type.py +++ b/ironic/drivers/hardware_type.py @@ -103,6 +103,11 @@ class AbstractHardwareType(object, metaclass=abc.ABCMeta): """List of supported vendor interfaces.""" return [noop.NoVendor] + @property + def supported_firmware_interfaces(self): + """List of supported firmware interfaces.""" + return [noop.NoFirmware] + def get_properties(self): """Get the properties of the hardware type. diff --git a/ironic/drivers/modules/fake.py b/ironic/drivers/modules/fake.py index 0a26efb4ce..5823fb3796 100644 --- a/ironic/drivers/modules/fake.py +++ b/ironic/drivers/modules/fake.py @@ -443,3 +443,24 @@ class FakeRescue(base.RescueInterface): def unrescue(self, task): sleep(CONF.fake.rescue_delay) return states.ACTIVE + + +class FakeFirmware(base.FirmwareInterface): + """Example implementation of a simple firmware interface.""" + + def get_properties(self): + return {} + + def validate(self, task): + pass + + @base.clean_step(priority=0, argsinfo={ + 'settings': {'description': ('List of Firmware components, each item ' + 'needs to contain a dictionary with name/value pairs'), + 'required': True}}) + def update(self, task, settings): + sleep(CONF.fake.firmware_delay) + + def cache_firmware_components(self, task): + sleep(CONF.fake.firmware_delay) + pass diff --git a/ironic/drivers/modules/noop.py b/ironic/drivers/modules/noop.py index 3c8cb97038..491e1db619 100644 --- a/ironic/drivers/modules/noop.py +++ b/ironic/drivers/modules/noop.py @@ -81,3 +81,13 @@ class NoBIOS(FailMixin, base.BIOSInterface): def cache_bios_settings(self, task): pass + + +class NoFirmware(FailMixin, base.FirmwareInterface): + """Firmware interface implementation that raises errors on all requests""" + + def update(self, task, settings): + _fail(self, task, settings) + + def cache_firmware_components(self, task): + pass diff --git a/ironic/objects/node.py b/ironic/objects/node.py index 1567baa9a5..f86b9f78a5 100644 --- a/ironic/objects/node.py +++ b/ironic/objects/node.py @@ -80,7 +80,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): # Version 1.36: Add boot_mode and secure_boot fields # Version 1.37: Add shard field # Version 1.38: Add parent_node field - VERSION = '1.38' + # Version 1.39: Add firmware_interface field + VERSION = '1.39' dbapi = db_api.get_instance() @@ -155,6 +156,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): 'boot_interface': object_fields.StringField(nullable=True), 'console_interface': object_fields.StringField(nullable=True), 'deploy_interface': object_fields.StringField(nullable=True), + 'firmware_interface': object_fields.StringField(nullable=True), 'inspect_interface': object_fields.StringField(nullable=True), 'management_interface': object_fields.StringField(nullable=True), 'network_interface': object_fields.StringField(nullable=True), @@ -662,6 +664,9 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): For versions prior to this, it should be set to None or removed. Version 1.37: shard was added. Default is None. For versions prior to this, it should be set to None or removed. + Version 1.39: firmware_interface field was added. Its default value is + None. For versions prior to this, it should be set to None (or + removed). :param target_version: the desired version of the object :param remove_unavailable_fields: True to remove fields that are @@ -677,7 +682,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): ('automated_clean', 28), ('protected_reason', 29), ('owner', 30), ('allocation_id', 31), ('description', 32), ('retired_reason', 33), ('lessee', 34), ('boot_mode', 36), - ('secure_boot', 36), ('shard', 37)] + ('secure_boot', 36), ('shard', 37), + ('firmware_interface', 39)] for name, minor in fields: self._adjust_field_to_version(name, None, target_version, diff --git a/ironic/tests/unit/api/base.py b/ironic/tests/unit/api/base.py index 6670be14e3..5f53e30735 100644 --- a/ironic/tests/unit/api/base.py +++ b/ironic/tests/unit/api/base.py @@ -72,8 +72,8 @@ class BaseApiTest(db_base.DbTestCase): def _make_app(self): # Determine where we are so we can set up paths in the config - root_dir = self.path_get() + root_dir = self.path_get() self.app_config = { 'app': { 'root': self.root_controller, diff --git a/ironic/tests/unit/api/controllers/v1/test_driver.py b/ironic/tests/unit/api/controllers/v1/test_driver.py index 6bf04297ff..645f1ce3ba 100644 --- a/ironic/tests/unit/api/controllers/v1/test_driver.py +++ b/ironic/tests/unit/api/controllers/v1/test_driver.py @@ -219,7 +219,7 @@ class TestListDrivers(base.BaseApiTest): for iface in driver_base.ALL_INTERFACES: if iface != 'bios': - if latest_if or iface not in ['rescue', 'storage']: + if latest_if or iface not in ['rescue', 'storage', 'firmware']: self.assertIn('default_%s_interface' % iface, data) self.assertIn('enabled_%s_interfaces' % iface, data) diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index 083bab870b..ce2c069300 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -8478,3 +8478,40 @@ class TestNodeParentNodePatch(test_api_base.BaseApiTest): '/nodes/%s' % self.child_node.uuid, body, headers=headers) self.assertEqual(http_client.OK, response.status_code) self.mock_update_node.assert_called_once() + + +class TestNodeFirmwareComponent(test_api_base.BaseApiTest): + + def setUp(self): + super(TestNodeFirmwareComponent, self).setUp() + self.version = "1.86" + self.node = obj_utils.create_test_node( + self.context, id=1) + + self.fw_cmp = obj_utils.create_test_firmware_component( + self.context, node_id=self.node.id) + self.fw_cmp2 = obj_utils.create_test_firmware_component( + self.context, node_id=self.node.id, component='BIOS') + + def test_get_all_firmware_components(self): + ret = self.get_json('/nodes/%s/firmware' % self.node.uuid, + headers={api_base.Version.string: self.version}) + expected_components = [ + {'created_at': ret['firmware'][0]['created_at'], + 'updated_at': ret['firmware'][0]['updated_at'], + 'component': 'BIOS', + 'initial_version': 'v1.0.0', 'current_version': 'v1.0.0', + 'last_version_flashed': None}, + {'created_at': ret['firmware'][1]['created_at'], + 'updated_at': ret['firmware'][1]['updated_at'], + 'component': 'bmc', + 'initial_version': 'v1.0.0', 'current_version': 'v1.0.0', + 'last_version_flashed': None}] + self.assertEqual({'firmware': expected_components}, ret) + + def test_wrong_version_get_all_firmware_components_old_version(self): + ret = self.get_json('/nodes/%s/firmware' % self.node.uuid, + headers={api_base.Version.string: "1.81"}, + expect_errors=True) + + self.assertEqual(http_client.NOT_FOUND, ret.status_int) diff --git a/ironic/tests/unit/api/test_acl.py b/ironic/tests/unit/api/test_acl.py index 4ac76eef96..0481843bd7 100644 --- a/ironic/tests/unit/api/test_acl.py +++ b/ironic/tests/unit/api/test_acl.py @@ -285,6 +285,10 @@ class TestRBACModelBeforeScopesBase(TestACLBase): value=fake_setting) db_utils.create_test_node_trait( node_id=fake_db_node['id']) + # Create a Fake Firmware Component BMC + db_utils.create_test_firmware_component( + node_id=fake_db_node['id'], + ) fake_history = db_utils.create_test_history(node_id=fake_db_node.id) fake_inventory = db_utils.create_test_inventory( node_id=fake_db_node.id) diff --git a/ironic/tests/unit/api/test_rbac_project_scoped.yaml b/ironic/tests/unit/api/test_rbac_project_scoped.yaml index f4a48df512..e52fe6a19c 100644 --- a/ironic/tests/unit/api/test_rbac_project_scoped.yaml +++ b/ironic/tests/unit/api/test_rbac_project_scoped.yaml @@ -3946,3 +3946,35 @@ lessee_cannot_get_a_nodes_children: method: get headers: *lessee_reader_headers assert_status: 404 + +# Node Firmware + +owner_reader_can_get_firmware_components: + path: '/v1/nodes/{owner_node_ident}/firmware' + method: get + headers: *owner_reader_headers + assert_status: 200 + +lessee_reader_can_get_firmware_components: + path: '/v1/nodes/{lessee_node_ident}/firmware' + method: get + headers: *lessee_reader_headers + assert_status: 200 + +third_party_admin_cannot_get_firmware_components: + path: '/v1/nodes/{owner_node_ident}/firmware' + method: get + headers: *third_party_admin_headers + assert_status: 404 + +service_can_get_firmware_components_owner_project: + path: '/v1/nodes/{owner_node_ident}/firmware' + method: get + headers: *service_headers_owner_project + assert_status: 200 + +service_cannot_get_firmware_components: + path: '/v1/nodes/{owner_node_ident}/firmware' + method: get + headers: *service_headers + assert_status: 404 diff --git a/ironic/tests/unit/api/test_rbac_system_scoped.yaml b/ironic/tests/unit/api/test_rbac_system_scoped.yaml index 8a56d31695..919c7c1cbe 100644 --- a/ironic/tests/unit/api/test_rbac_system_scoped.yaml +++ b/ironic/tests/unit/api/test_rbac_system_scoped.yaml @@ -2340,3 +2340,23 @@ parent_node_patch_by_reader: headers: *reader_headers body: *patch_parent_node assert_status: 403 + +# Node Firmware - baremetal:node:firmware:get + +nodes_firmware_component_get_admin: + path: '/v1/nodes/{node_ident}/firmware' + method: get + headers: *admin_headers + assert_status: 200 + +nodes_firmware_component_get_member: + path: '/v1/nodes/{node_ident}/firmware' + method: get + headers: *scoped_member_headers + assert_status: 200 + +nodes_firmware_component_get_reader: + path: '/v1/nodes/{node_ident}/firmware' + method: get + headers: *reader_headers + assert_status: 200 diff --git a/ironic/tests/unit/common/test_driver_factory.py b/ironic/tests/unit/common/test_driver_factory.py index c4857d21ce..dd569fda7c 100644 --- a/ironic/tests/unit/common/test_driver_factory.py +++ b/ironic/tests/unit/common/test_driver_factory.py @@ -378,6 +378,11 @@ class TestFakeHardware(hardware_type.AbstractHardwareType): """List of supported deploy interfaces.""" return [fake.FakeDeploy] + @property + def supported_firmware_interfaces(self): + """List of supported firmware interfaces.""" + return [fake.FakeFirmware] + @property def supported_inspect_interfaces(self): """List of supported inspect interfaces.""" @@ -586,6 +591,7 @@ class HardwareTypeLoadTestCase(db_base.DbTestCase): 'boot': set(['fake']), 'console': set(['fake', 'no-console']), 'deploy': set(['fake']), + 'firmware': set(['fake', 'no-firmware']), 'inspect': set(['fake', 'no-inspect']), 'management': set(['fake']), 'network': set(['noop']), diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py index b8d4ccebd5..582c87d80c 100644 --- a/ironic/tests/unit/conductor/test_manager.py +++ b/ironic/tests/unit/conductor/test_manager.py @@ -3578,7 +3578,8 @@ class MiscTestCase(mgr_utils.ServiceSetUpMixin, mgr_utils.CommonMixIn, 'network': {'result': True}, 'storage': {'result': True}, 'rescue': {'result': True}, - 'bios': {'result': True}} + 'bios': {'result': True}, + 'firmware': {'result': True}} self.assertEqual(expected, ret) mock_iwdi.assert_called_once_with(self.context, expected_info) diff --git a/ironic/tests/unit/drivers/test_base.py b/ironic/tests/unit/drivers/test_base.py index 2d41174b1a..6d63e51f3a 100644 --- a/ironic/tests/unit/drivers/test_base.py +++ b/ironic/tests/unit/drivers/test_base.py @@ -839,12 +839,43 @@ class TestManagementInterface(base.TestCase): management.get_mac_addresses, task_mock) +class MyFirmwareInterface(driver_base.FirmwareInterface): + + def get_properties(self): + pass + + def validate(self, task): + pass + + @driver_base.cache_firmware_components + def update(self, task, settings): + return "return_update" + + def cache_firmware_components(self, task): + pass + + +class TestFirmwareInterface(base.TestCase): + + @mock.patch.object(MyFirmwareInterface, 'cache_firmware_components', + autospec=True) + def test_update_with_wrapper(self, cache_firmware_components_mock): + firmware = MyFirmwareInterface() + task_mock = mock.MagicMock() + + actual = firmware.update(task_mock, "") + cache_firmware_components_mock.assert_called_once_with( + firmware, task_mock) + self.assertEqual(actual, "return_update") + + class TestBareDriver(base.TestCase): def test_class_variables(self): self.assertEqual(['boot', 'deploy', 'management', 'network', 'power'], driver_base.BareDriver().core_interfaces) self.assertEqual( - ['bios', 'console', 'inspect', 'raid', 'rescue', 'storage'], + ['bios', 'console', 'firmware', 'inspect', 'raid', + 'rescue', 'storage'], driver_base.BareDriver().optional_interfaces ) diff --git a/ironic/tests/unit/objects/test_node.py b/ironic/tests/unit/objects/test_node.py index 05793e3a61..2b607cdf06 100644 --- a/ironic/tests/unit/objects/test_node.py +++ b/ironic/tests/unit/objects/test_node.py @@ -1377,6 +1377,68 @@ class TestConvertToVersion(db_base.DbTestCase): self.assertIsNone(node.secure_boot) self.assertEqual({}, node.obj_get_changes()) + def test_firmware_supported_missing(self): + # firmware_interface not set, should be set to default. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + delattr(node, 'firmware_interface') + node.obj_reset_changes() + + node._convert_to_version("1.39") + + self.assertIsNone(node.firmware_interface) + self.assertEqual({'firmware_interface': None}, + node.obj_get_changes()) + + def test_firmware_supported_set(self): + # firmware_interface set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.firmware_interface = 'fake' + node.obj_reset_changes() + node._convert_to_version("1.39") + self.assertEqual('fake', node.firmware_interface) + self.assertEqual({}, node.obj_get_changes()) + + def test_firmware_unsupported_missing(self): + # firmware_interface not set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + delattr(node, 'firmware_interface') + node.obj_reset_changes() + node._convert_to_version("1.38") + self.assertNotIn('firmware_interface', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_firmware_unsupported_set_remove(self): + # firmware_interface set, should be removed. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.firmware_interface = 'fake' + node.obj_reset_changes() + node._convert_to_version("1.38") + self.assertNotIn('firmware_interface', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_firmware_unsupported_set_no_remove_non_default(self): + # firmware_interface set, should be set to default. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.firmware_interface = 'fake' + node.obj_reset_changes() + node._convert_to_version("1.38", False) + self.assertIsNone(node.firmware_interface) + self.assertEqual({'firmware_interface': None}, node.obj_get_changes()) + + def test_firmware_unsupported_set_no_remove_default(self): + # firmware_interface set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.firmware_interface = None + node.obj_reset_changes() + node._convert_to_version("1.38", False) + self.assertIsNone(node.firmware_interface) + self.assertEqual({}, node.obj_get_changes()) + class TestNodePayloads(db_base.DbTestCase): diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index d17739e276..ab9f64693c 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -676,7 +676,7 @@ class TestObject(_LocalTest, _TestObject): # version bump. It is an MD5 hash of the object fields and remotable methods. # The fingerprint values should only be changed if there is a version bump. expected_object_fingerprints = { - 'Node': '1.38-7e7fdaa2c2bb01153ad567c9f1081cb7', + 'Node': '1.39-ee3f5ff28b79f9fabf84a50e34a71684', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Port': '1.11-97bf15b61224f26c65e90f007d78bfd2', diff --git a/setup.cfg b/setup.cfg index 915d50ccce..2fd0e89814 100644 --- a/setup.cfg +++ b/setup.cfg @@ -93,6 +93,10 @@ ironic.hardware.interfaces.deploy = fake = ironic.drivers.modules.fake:FakeDeploy ramdisk = ironic.drivers.modules.ramdisk:RamdiskDeploy +ironic.hardware.interfaces.firmware = + fake = ironic.drivers.modules.fake:FakeFirmware + no-firmware = ironic.drivers.modules.noop:NoFirmware + ironic.hardware.interfaces.inspect = fake = ironic.drivers.modules.fake:FakeInspect idrac = ironic.drivers.modules.drac.inspect:DracInspect