From 254d37033172fb06672fc5743d8b05150208ffb7 Mon Sep 17 00:00:00 2001 From: zshi Date: Tue, 17 Oct 2017 18:37:39 +0800 Subject: [PATCH] Add Node BIOS support - REST API Change-Id: Ie7570736498b4750eff2d9262f63ab0960b3b594 Partial-Bug: #1712032 Co-Authored-By: Yolanda Robla --- .../contributor/webapi-version-history.rst | 11 ++ ironic/api/controllers/v1/bios.py | 127 ++++++++++++++++++ ironic/api/controllers/v1/driver.py | 6 + ironic/api/controllers/v1/node.py | 53 +++++--- ironic/api/controllers/v1/utils.py | 10 ++ ironic/api/controllers/v1/versions.py | 5 +- ironic/common/policy.py | 7 + ironic/common/release_mappings.py | 2 +- .../unit/api/controllers/v1/test_node.py | 84 +++++++++++- ironic/tests/unit/objects/utils.py | 25 ++++ .../notes/add-node-bios-9c1c3d442e8acdac.yaml | 6 + 11 files changed, 317 insertions(+), 19 deletions(-) create mode 100644 ironic/api/controllers/v1/bios.py create mode 100644 releasenotes/notes/add-node-bios-9c1c3d442e8acdac.yaml diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index c42e7cea41..5492a348bf 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,17 @@ REST API Version History ======================== +1.40 (Rocky, master) +--------------------- + +Added BIOS properties as sub resources of nodes: + +* GET /v1/nodes//bios +* GET /v1/nodes//bios/ + +Added ``bios_interface`` field to the node object to allow getting and +setting the interface. + 1.39 (Rocky, master) -------------------- diff --git a/ironic/api/controllers/v1/bios.py b/ironic/api/controllers/v1/bios.py new file mode 100644 index 0000000000..a9587b39d5 --- /dev/null +++ b/ironic/api/controllers/v1/bios.py @@ -0,0 +1,127 @@ +# Copyright 2018 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 +import pecan +from pecan import rest +import wsme +from wsme import types as wtypes + +from ironic.api.controllers import base +from ironic.api.controllers import link +from ironic.api.controllers.v1 import types +from ironic.api.controllers.v1 import utils as api_utils +from ironic.api import expose +from ironic.common import exception +from ironic.common import policy +from ironic import objects + +METRICS = metrics_utils.get_metrics_logger(__name__) + + +class BIOSSetting(base.APIBase): + """API representation of a BIOS setting.""" + + name = wsme.wsattr(wtypes.text) + + value = wsme.wsattr(wtypes.text) + + links = wsme.wsattr([link.Link], readonly=True) + + def __init__(self, **kwargs): + self.fields = [] + fields = list(objects.BIOSSetting.fields) + for k in fields: + if hasattr(self, k): + self.fields.append(k) + value = kwargs.get(k, wtypes.Unset) + setattr(self, k, value) + + @staticmethod + def _convert_with_links(bios, node_uuid, url): + """Add links to the bios setting.""" + name = bios.name + bios.links = [link.Link.make_link('self', url, 'nodes', + "%s/bios/%s" % (node_uuid, name)), + link.Link.make_link('bookmark', url, 'nodes', + "%s/bios/%s" % (node_uuid, name), + bookmark=True)] + return bios + + @classmethod + def convert_with_links(cls, rpc_bios, node_uuid): + """Add links to the bios setting.""" + bios = BIOSSetting(**rpc_bios.as_dict()) + return cls._convert_with_links(bios, node_uuid, pecan.request.host_url) + + +class BIOSSettingsCollection(wtypes.Base): + """API representation of the bios settings for a node.""" + + bios = [BIOSSetting] + """Node bios settings list""" + + @staticmethod + def collection_from_list(node_ident, bios_settings): + col = BIOSSettingsCollection() + + bios_list = [] + for bios_setting in bios_settings: + bios_list.append(BIOSSetting.convert_with_links(bios_setting, + node_ident)) + col.bios = bios_list + return col + + +class NodeBiosController(rest.RestController): + """REST controller for bios.""" + + def __init__(self, node_ident=None): + super(NodeBiosController, self).__init__() + self.node_ident = node_ident + + @METRICS.timer('NodeBiosController.get_all') + @expose.expose(BIOSSettingsCollection) + def get_all(self): + """List node bios settings.""" + cdict = pecan.request.context.to_policy_values() + policy.authorize('baremetal:node:bios:get', cdict, cdict) + + node = api_utils.get_rpc_node(self.node_ident) + settings = objects.BIOSSettingList.get_by_node_id( + pecan.request.context, node.id) + return BIOSSettingsCollection.collection_from_list(self.node_ident, + settings) + + @METRICS.timer('NodeBiosController.get_one') + @expose.expose({wtypes.text: BIOSSetting}, types.name) + def get_one(self, setting_name): + """Retrieve information about the given bios setting. + + :param setting_name: Logical name of the setting to retrieve. + """ + cdict = pecan.request.context.to_policy_values() + policy.authorize('baremetal:node:bios:get', cdict, cdict) + + node = api_utils.get_rpc_node(self.node_ident) + try: + setting = objects.BIOSSetting.get(pecan.request.context, node.id, + setting_name) + except exception.BIOSSettingNotFound: + raise exception.BIOSSettingNotFound(node=node.uuid, + name=setting_name) + + return {setting_name: BIOSSetting.convert_with_links(setting, + node.uuid)} diff --git a/ironic/api/controllers/v1/driver.py b/ironic/api/controllers/v1/driver.py index 3a93efbcee..d3f3c508cf 100644 --- a/ironic/api/controllers/v1/driver.py +++ b/ironic/api/controllers/v1/driver.py @@ -79,6 +79,10 @@ def hide_fields_in_newer_versions(obj): obj.default_rescue_interface = wsme.Unset obj.enabled_rescue_interfaces = wsme.Unset + if not api_utils.allow_bios_interface(): + obj.default_bios_interface = wsme.Unset + obj.enabled_bios_interfaces = wsme.Unset + class Driver(base.APIBase): """API representation of a driver.""" @@ -99,6 +103,7 @@ class Driver(base.APIBase): """A list containing links to driver properties""" """Default interface for a hardware type""" + default_bios_interface = wtypes.text default_boot_interface = wtypes.text default_console_interface = wtypes.text default_deploy_interface = wtypes.text @@ -112,6 +117,7 @@ class Driver(base.APIBase): default_vendor_interface = wtypes.text """A list of enabled interfaces for a hardware type""" + enabled_bios_interfaces = [wtypes.text] enabled_boot_interfaces = [wtypes.text] enabled_console_interfaces = [wtypes.text] enabled_deploy_interfaces = [wtypes.text] diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index b6d2903054..925c5c705a 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -28,6 +28,7 @@ from wsme import types as wtypes from ironic.api.controllers import base from ironic.api.controllers import link +from ironic.api.controllers.v1 import bios from ironic.api.controllers.v1 import collection from ironic.api.controllers.v1 import notification_utils as notify from ironic.api.controllers.v1 import port @@ -162,6 +163,9 @@ def hide_fields_in_newer_versions(obj): if not api_utils.allow_rescue_interface(): obj.rescue_interface = wsme.Unset + if not api_utils.allow_bios_interface(): + obj.bios_interface = wsme.Unset + def update_state_in_older_versions(obj): """Change provision state names for API backwards compatibility. @@ -1040,6 +1044,9 @@ class Node(base.APIBase): traits = wtypes.ArrayType(str) """The traits associated with this node""" + bios_interface = wsme.wsattr(wtypes.text) + """The bios interface to be used for this node""" + # NOTE(deva): "conductor_affinity" shouldn't be presented on the # API because it's an internal value. Don't add it here. @@ -1194,7 +1201,8 @@ class Node(base.APIBase): deploy_interface=None, inspect_interface=None, management_interface=None, power_interface=None, raid_interface=None, vendor_interface=None, - storage_interface=None, traits=[], rescue_interface=None) + storage_interface=None, traits=[], rescue_interface=None, + bios_interface=None) # NOTE(matty_dubs): The chassis_uuid getter() is based on the # _chassis_uuid variable: sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12' @@ -1463,6 +1471,7 @@ class NodesController(rest.RestController): 'vifs': NodeVIFController, 'volume': volume.VolumeController, 'traits': NodeTraitsController, + 'bios': bios.NodeBiosController, } @pecan.expose() @@ -1476,7 +1485,9 @@ class NodesController(rest.RestController): if ((remainder[0] == 'portgroups' and not api_utils.allow_portgroups_subcontrollers()) or (remainder[0] == 'vifs' - and not api_utils.allow_vifs_subcontroller())): + and not api_utils.allow_vifs_subcontroller()) + or (remainder[0] == 'bios' and + not api_utils.allow_bios_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 @@ -1827,6 +1838,10 @@ class NodesController(rest.RestController): and node.storage_interface is not wtypes.Unset): raise exception.NotAcceptable() + if (not api_utils.allow_bios_interface() and + node.bios_interface is not wtypes.Unset): + raise exception.NotAcceptable() + if node.traits is not wtypes.Unset: msg = _("Cannot specify node traits on node creation. Traits must " "be set via the node traits API.") @@ -1874,19 +1889,8 @@ class NodesController(rest.RestController): chassis_uuid=api_node.chassis_uuid) return api_node - @METRICS.timer('NodesController.patch') - @wsme.validate(types.uuid, [NodePatchType]) - @expose.expose(Node, types.uuid_or_name, body=[NodePatchType]) - def patch(self, node_ident, patch): - """Update an existing node. - - :param node_ident: UUID or logical name of a node. - :param patch: a json PATCH document to apply to this node. - """ - context = pecan.request.context - cdict = context.to_policy_values() - policy.authorize('baremetal:node:update', cdict, cdict) - + # NOTE (yolanda): isolate validation to avoid patch too complex pep error + def _validate_patch(self, patch): if self.from_chassis: raise exception.OperationNotPermitted() @@ -1917,6 +1921,25 @@ class NodesController(rest.RestController): if r_interface and not api_utils.allow_rescue_interface(): raise exception.NotAcceptable() + b_interface = api_utils.get_patch_values(patch, '/bios_interface') + if b_interface and not api_utils.allow_bios_interface(): + raise exception.NotAcceptable() + + @METRICS.timer('NodesController.patch') + @wsme.validate(types.uuid, [NodePatchType]) + @expose.expose(Node, types.uuid_or_name, body=[NodePatchType]) + def patch(self, node_ident, patch): + """Update an existing node. + + :param node_ident: UUID or logical name of a node. + :param patch: a json PATCH document to apply to this node. + """ + context = pecan.request.context + cdict = context.to_policy_values() + policy.authorize('baremetal:node:update', cdict, cdict) + + self._validate_patch(patch) + rpc_node = api_utils.get_rpc_node_with_suffix(node_ident) remove_inst_uuid_patch = [{'op': 'remove', 'path': '/instance_uuid'}] diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 2c9a877472..dbd579201f 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -362,6 +362,8 @@ def check_allowed_fields(fields): """ if fields is None: return + if 'bios_interface' in fields and not allow_bios_interface(): + raise exception.NotAcceptable() if 'network_interface' in fields and not allow_network_interface(): raise exception.NotAcceptable() if 'resource_class' in fields and not allow_resource_class(): @@ -693,6 +695,14 @@ def allow_rescue_interface(): return pecan.request.version.minor >= versions.MINOR_38_RESCUE_INTERFACE +def allow_bios_interface(): + """Check if we should support bios interface. + + Version 1.40 of the API added support for bios interface. + """ + return pecan.request.version.minor >= versions.MINOR_40_BIOS_INTERFACE + + def get_controller_reserved_names(cls): """Get reserved names for a given controller. diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 87be879f55..171f3d1af0 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -76,6 +76,8 @@ BASE_VERSION = 1 # v1.37: Add node traits. # v1.38: Add rescue and unrescue provision states # v1.39: Add inspect wait provision state. +# v1.40: Add bios.properties. +# Add bios_interface to the node object. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -117,6 +119,7 @@ MINOR_36_AGENT_VERSION_HEARTBEAT = 36 MINOR_37_NODE_TRAITS = 37 MINOR_38_RESCUE_INTERFACE = 38 MINOR_39_INSPECT_WAIT = 39 +MINOR_40_BIOS_INTERFACE = 40 # When adding another version, update: # - MINOR_MAX_VERSION @@ -124,7 +127,7 @@ MINOR_39_INSPECT_WAIT = 39 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_39_INSPECT_WAIT +MINOR_MAX_VERSION = MINOR_40_BIOS_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 7589b7d520..0d57876d4c 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -199,6 +199,13 @@ node_policies = [ [{'path': '/nodes/{node_ident}/traits', 'method': 'DELETE'}, {'path': '/nodes/{node_ident}/traits/{trait}', 'method': 'DELETE'}]), + + policy.DocumentedRuleDefault( + 'baremetal:node:bios:get', + 'rule:is_admin or rule:is_observer', + 'Retrieve Node BIOS information', + [{'path': '/nodes/{node_ident}/bios', 'method': 'GET'}, + {'path': '/nodes/{node_ident}/bios/{setting}', 'method': 'GET'}]) ] port_policies = [ diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index f8471cb690..e4edecb034 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -100,7 +100,7 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.39', + 'api': '1.40', 'rpc': '1.44', 'objects': { 'Node': ['1.24'], diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index 14c5a91415..228de8c4c2 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -121,6 +121,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertNotIn('traits', data['nodes'][0]) # never expose the chassis_id self.assertNotIn('chassis_id', data['nodes'][0]) + self.assertNotIn('bios_interface', data['nodes'][0]) def test_get_one(self): node = obj_utils.create_test_node(self.context, @@ -156,6 +157,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn('traits', data) # never expose the chassis_id self.assertNotIn('chassis_id', data) + self.assertIn('bios_interface', data) def test_get_one_with_json(self): # Test backward compatibility with guess_content_type_from_ext @@ -227,6 +229,13 @@ class TestListNodes(test_api_base.BaseApiTest): headers={api_base.Version.string: '1.36'}) self.assertNotIn('traits', data) + def test_node_bios_hidden_in_lower_version(self): + node = obj_utils.create_test_node(self.context) + data = self.get_json( + '/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.39'}) + self.assertNotIn('bios_interface', data) + def test_node_inspect_wait_state_between_api_versions(self): node = obj_utils.create_test_node(self.context, provision_state='inspect wait') @@ -2345,10 +2354,11 @@ class TestPost(test_api_base.BaseApiTest): self.assertEqual('neutron', result['network_interface']) def test_create_node_specify_interfaces(self): - headers = {api_base.Version.string: '1.38'} + headers = {api_base.Version.string: '1.40'} all_interface_fields = api_utils.V31_FIELDS + ['network_interface', 'rescue_interface', - 'storage_interface'] + 'storage_interface', + 'bios_interface'] for field in all_interface_fields: if field == 'network_interface': cfg.CONF.set_override('enabled_%ss' % field, ['flat']) @@ -2841,6 +2851,14 @@ class TestPost(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertEqual(http_client.BAD_REQUEST, response.status_int) + def test_create_node_invalid_bios_interface(self): + ndict = test_api_utils.post_get_test_node(bios_interface='foo') + response = self.post_json('/nodes', ndict, expect_errors=True, + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + class TestDelete(test_api_base.BaseApiTest): @@ -4404,6 +4422,68 @@ class TestAttachDetachVif(test_api_base.BaseApiTest): self.assertTrue(ret.json['error_message']) +class TestBIOS(test_api_base.BaseApiTest): + + def setUp(self): + super(TestBIOS, self).setUp() + self.version = "1.40" + self.node = obj_utils.create_test_node( + self.context, id=1) + self.bios = obj_utils.create_test_bios_setting(self.context, + node_id=self.node.id) + + def test_get_all_bios(self): + ret = self.get_json('/nodes/%s/bios' % self.node.uuid, + headers={api_base.Version.string: self.version}) + + expected_json = [ + {u'created_at': ret['bios'][0]['created_at'], + u'updated_at': ret['bios'][0]['updated_at'], + u'links': [ + {u'href': u'http://localhost/v1/nodes/' + self.node.uuid + + '/bios/virtualization', u'rel': u'self'}, + {u'href': u'http://localhost/nodes/' + self.node.uuid + + '/bios/virtualization', u'rel': u'bookmark'}], u'name': + u'virtualization', u'value': u'on'}] + self.assertEqual({u'bios': expected_json}, ret) + + def test_get_all_bios_fails_with_bad_version(self): + ret = self.get_json('/nodes/%s/bios' % self.node.uuid, + headers={api_base.Version.string: "1.39"}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, ret.status_code) + + def test_get_one_bios(self): + ret = self.get_json('/nodes/%s/bios/virtualization' % self.node.uuid, + headers={api_base.Version.string: self.version}) + + expected_json = { + u'virtualization': { + u'created_at': ret['virtualization']['created_at'], + u'updated_at': ret['virtualization']['updated_at'], + u'links': [ + {u'href': u'http://localhost/v1/nodes/' + self.node.uuid + + '/bios/virtualization', u'rel': u'self'}, + {u'href': u'http://localhost/nodes/' + self.node.uuid + + '/bios/virtualization', u'rel': u'bookmark'}], + u'name': u'virtualization', u'value': u'on'}} + self.assertEqual(expected_json, ret) + + def test_get_one_bios_fails_with_bad_version(self): + ret = self.get_json('/nodes/%s/bios/virtualization' % self.node.uuid, + headers={api_base.Version.string: "1.39"}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, ret.status_code) + + def test_get_one_bios_fails_if_not_found(self): + ret = self.get_json('/nodes/%s/bios/fake_setting' % self.node.uuid, + headers={api_base.Version.string: self.version}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, ret.status_code) + self.assertIn("fake_setting", ret.json['error_message']) + self.assertNotIn(self.node.id, ret.json['error_message']) + + class TestTraits(test_api_base.BaseApiTest): def setUp(self): diff --git a/ironic/tests/unit/objects/utils.py b/ironic/tests/unit/objects/utils.py index 77acd2dd96..99fb6c18d9 100644 --- a/ironic/tests/unit/objects/utils.py +++ b/ironic/tests/unit/objects/utils.py @@ -230,6 +230,31 @@ def create_test_volume_target(ctxt, **kw): return volume_target +def get_test_bios_setting(ctxt, **kw): + """Return a BiosSettingList object with appropriate attributes. + + NOTE: The object leaves the attributes marked as changed, such + that a create() could be used to commit it to the DB. + """ + kw['object_type'] = 'bios' + db_bios_setting = db_utils.get_test_bios_setting(**kw) + bios_setting = objects.BIOSSetting(ctxt) + for key in db_bios_setting: + setattr(bios_setting, key, db_bios_setting[key]) + return bios_setting + + +def create_test_bios_setting(ctxt, **kw): + """Create and return a test bios setting list object. + + Create a BIOS setting list in the DB and return a BIOSSettingList + object with appropriate attributes. + """ + bios_setting = get_test_bios_setting(ctxt, **kw) + bios_setting.create() + return bios_setting + + def get_payloads_with_schemas(from_module): """Get the Payload classes with SCHEMAs defined. diff --git a/releasenotes/notes/add-node-bios-9c1c3d442e8acdac.yaml b/releasenotes/notes/add-node-bios-9c1c3d442e8acdac.yaml new file mode 100644 index 0000000000..674bb448cc --- /dev/null +++ b/releasenotes/notes/add-node-bios-9c1c3d442e8acdac.yaml @@ -0,0 +1,6 @@ +--- +features: + - Adds support for reading and changing the node's ``bios_interface`` field + and enables the GET endpoints to check BIOS settings, if they have already + been cached. This requires a compatible ``bios_interface`` to be set. + This feature is available starting with API version 1.40.