From f16c6570bf0ffe70f83b85b963a4210c3990c573 Mon Sep 17 00:00:00 2001 From: Jim Rollenhagen Date: Wed, 20 Jul 2016 14:20:45 -0700 Subject: [PATCH] Add node.resource_class field This adds the "resource_class" field to the node table, object, and API, as well as a database migration to go with it. Change-Id: I936f2e7b2f4d26e01354e826e5595ff021c3a55c Partial-Bug: #1604916 --- doc/source/webapi/v1.rst | 4 + ironic/api/controllers/v1/node.py | 48 +++++- ironic/api/controllers/v1/utils.py | 40 ++++- ironic/api/controllers/v1/versions.py | 4 +- ...dd34e1f1303b_add_resource_class_to_node.py | 33 ++++ ironic/db/sqlalchemy/api.py | 2 + ironic/db/sqlalchemy/models.py | 1 + ironic/objects/node.py | 6 +- ironic/tests/unit/api/utils.py | 10 +- ironic/tests/unit/api/v1/test_nodes.py | 159 ++++++++++++++++++ ironic/tests/unit/api/v1/test_utils.py | 42 ++++- .../unit/db/sqlalchemy/test_migrations.py | 7 + ironic/tests/unit/db/test_nodes.py | 6 +- ironic/tests/unit/db/utils.py | 1 + ironic/tests/unit/objects/test_objects.py | 2 +- ...-node-resource-class-c31e26df4196293e.yaml | 13 ++ 16 files changed, 348 insertions(+), 30 deletions(-) create mode 100644 ironic/db/sqlalchemy/alembic/versions/dd34e1f1303b_add_resource_class_to_node.py create mode 100644 releasenotes/notes/add-node-resource-class-c31e26df4196293e.yaml diff --git a/doc/source/webapi/v1.rst b/doc/source/webapi/v1.rst index 0b4f929a9d..2f0a87bd4d 100644 --- a/doc/source/webapi/v1.rst +++ b/doc/source/webapi/v1.rst @@ -32,6 +32,10 @@ always requests the newest supported API version. API Versions History -------------------- +**1.21** + + Add node ``resource_class`` field. + **1.20** Add node ``network_interface`` field. diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index aa52319017..d714ae8399 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -140,6 +140,9 @@ def hide_fields_in_newer_versions(obj): if pecan.request.version.minor < versions.MINOR_20_NETWORK_INTERFACE: obj.network_interface = wsme.Unset + if not api_utils.allow_resource_class(): + obj.resource_class = wsme.Unset + def update_state_in_older_versions(obj): """Change provision state names for API backwards compatability. @@ -699,6 +702,11 @@ class Node(base.APIBase): extra = {wtypes.text: types.jsontype} """This node's meta data""" + resource_class = wsme.wsattr(wtypes.StringType(max_length=80)) + """The resource class for the node, useful for classifying or grouping + nodes. Used, for example, to classify nodes in Nova's placement + engine.""" + # NOTE: properties should use a class to enforce required properties # current list: arch, cpus, disk, ram, image properties = {wtypes.text: types.jsontype} @@ -819,7 +827,7 @@ class Node(base.APIBase): inspection_finished_at=None, inspection_started_at=time, console_enabled=False, clean_step={}, raid_config=None, target_raid_config=None, - network_interface='flat') + network_interface='flat', resource_class='baremetal-gold') # NOTE(matty_dubs): The chassis_uuid getter() is based on the # _chassis_uuid variable: sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12' @@ -1006,6 +1014,7 @@ class NodesController(rest.RestController): def _get_nodes_collection(self, chassis_uuid, instance_uuid, associated, maintenance, provision_state, marker, limit, sort_key, sort_dir, driver=None, + resource_class=None, resource_url=None, fields=None): if self.from_chassis and not chassis_uuid: raise exception.MissingParameterValue( @@ -1038,6 +1047,8 @@ class NodesController(rest.RestController): filters['provision_state'] = provision_state if driver: filters['driver'] = driver + if resource_class is not None: + filters['resource_class'] = resource_class nodes = objects.Node.list(pecan.request.context, limit, marker_obj, sort_key=sort_key, sort_dir=sort_dir, @@ -1128,11 +1139,11 @@ class NodesController(rest.RestController): @METRICS.timer('NodesController.get_all') @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, types.boolean, wtypes.text, types.uuid, int, wtypes.text, - wtypes.text, wtypes.text, types.listtype) + wtypes.text, wtypes.text, types.listtype, wtypes.text) def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None, maintenance=None, provision_state=None, marker=None, limit=None, sort_key='id', sort_dir='asc', driver=None, - fields=None): + fields=None, resource_class=None): """Retrieve a list of nodes. :param chassis_uuid: Optional UUID of a chassis, to get only nodes for @@ -1153,28 +1164,34 @@ class NodesController(rest.RestController): :param sort_dir: direction to sort. "asc" or "desc". Default: asc. :param driver: Optional string value to get only nodes using that driver. + :param resource_class: Optional string value to get only nodes with + that resource_class. :param fields: Optional, a list with a specified set of fields of the resource to be returned. """ api_utils.check_allow_specify_fields(fields) + api_utils.check_allowed_fields(fields) api_utils.check_for_invalid_state_and_allow_filter(provision_state) api_utils.check_allow_specify_driver(driver) - api_utils.check_allow_specify_network_interface_in_fields(fields) + api_utils.check_allow_specify_resource_class(resource_class) if fields is None: fields = _DEFAULT_RETURN_FIELDS return self._get_nodes_collection(chassis_uuid, instance_uuid, associated, maintenance, provision_state, marker, limit, sort_key, sort_dir, - driver, fields=fields) + driver=driver, + resource_class=resource_class, + fields=fields) @METRICS.timer('NodesController.detail') @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, types.boolean, wtypes.text, types.uuid, int, wtypes.text, - wtypes.text, wtypes.text) + wtypes.text, wtypes.text, wtypes.text) def detail(self, chassis_uuid=None, instance_uuid=None, associated=None, maintenance=None, provision_state=None, marker=None, - limit=None, sort_key='id', sort_dir='asc', driver=None): + limit=None, sort_key='id', sort_dir='asc', driver=None, + resource_class=None): """Retrieve a list of nodes with detail. :param chassis_uuid: Optional UUID of a chassis, to get only nodes for @@ -1195,9 +1212,12 @@ class NodesController(rest.RestController): :param sort_dir: direction to sort. "asc" or "desc". Default: asc. :param driver: Optional string value to get only nodes using that driver. + :param resource_class: Optional string value to get only nodes with + that resource_class. """ api_utils.check_for_invalid_state_and_allow_filter(provision_state) api_utils.check_allow_specify_driver(driver) + api_utils.check_allow_specify_resource_class(resource_class) # /detail should only work against collections parent = pecan.request.path.split('/')[:-1][-1] if parent != "nodes": @@ -1208,7 +1228,9 @@ class NodesController(rest.RestController): associated, maintenance, provision_state, marker, limit, sort_key, sort_dir, - driver, resource_url) + driver=driver, + resource_class=resource_class, + resource_url=resource_url) @METRICS.timer('NodesController.validate') @expose.expose(wtypes.text, types.uuid_or_name, types.uuid) @@ -1247,7 +1269,7 @@ class NodesController(rest.RestController): raise exception.OperationNotPermitted() api_utils.check_allow_specify_fields(fields) - api_utils.check_allow_specify_network_interface_in_fields(fields) + api_utils.check_allowed_fields(fields) rpc_node = api_utils.get_rpc_node(node_ident) return Node.convert_with_links(rpc_node, fields=fields) @@ -1262,6 +1284,10 @@ class NodesController(rest.RestController): if self.from_chassis: raise exception.OperationNotPermitted() + if (not api_utils.allow_resource_class() and + node.resource_class is not wtypes.Unset): + raise exception.NotAcceptable() + n_interface = node.network_interface if (not api_utils.allow_network_interface() and n_interface is not wtypes.Unset): @@ -1322,6 +1348,10 @@ class NodesController(rest.RestController): if self.from_chassis: raise exception.OperationNotPermitted() + resource_class = api_utils.get_patch_values(patch, '/resource_class') + if resource_class and not api_utils.allow_resource_class(): + raise exception.NotAcceptable() + n_interfaces = api_utils.get_patch_values(patch, '/network_interface') if n_interfaces and not api_utils.allow_network_interface(): raise exception.NotAcceptable() diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 51ccef062f..00b6a17d99 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -240,16 +240,17 @@ def check_allow_specify_fields(fields): raise exception.NotAcceptable() -def check_allow_specify_network_interface_in_fields(fields): - """Check if fetching a network_interface attribute is allowed. +def check_allowed_fields(fields): + """Check if fetching a particular field is allowed. - Version 1.20 of the API allows to fetching a network_interface - attribute. This method check if the required version is being - requested. + This method checks if the required version is being requested for fields + that are only allowed to be fetched in a particular API version. """ - if (fields is not None - and 'network_interface' in fields - and not allow_network_interface()): + if fields is None: + return + if 'network_interface' in fields and not allow_network_interface(): + raise exception.NotAcceptable() + if 'resource_class' in fields and not allow_resource_class(): raise exception.NotAcceptable() @@ -303,6 +304,20 @@ def check_allow_specify_driver(driver): 'opr': versions.MINOR_16_DRIVER_FILTER}) +def check_allow_specify_resource_class(resource_class): + """Check if filtering nodes by resource_class is allowed. + + Version 1.21 of the API allows filtering nodes by resource_class. + """ + if (resource_class is not None and pecan.request.version.minor < + versions.MINOR_21_RESOURCE_CLASS): + raise exception.NotAcceptable(_( + "Request not acceptable. The minimal required API version " + "should be %(base)s.%(opr)s") % + {'base': versions.BASE_VERSION, + 'opr': versions.MINOR_21_RESOURCE_CLASS}) + + def initial_node_provision_state(): """Return node state to use by default when creating new nodes. @@ -359,6 +374,15 @@ def allow_network_interface(): versions.MINOR_20_NETWORK_INTERFACE) +def allow_resource_class(): + """Check if we should support resource_class node field. + + Version 1.21 of the API added support for resource_class. + """ + return (pecan.request.version.minor >= + versions.MINOR_21_RESOURCE_CLASS) + + 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 d2e75862b1..152f5e64a9 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -50,6 +50,7 @@ BASE_VERSION = 1 # v1.18: Add port.internal_info. # v1.19: Add port.local_link_connection and port.pxe_enabled. # v1.20: Add node.network_interface +# v1.21: Add node.resource_class MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -72,11 +73,12 @@ MINOR_17_ADOPT_VERB = 17 MINOR_18_PORT_INTERNAL_INFO = 18 MINOR_19_PORT_ADVANCED_NET_FIELDS = 19 MINOR_20_NETWORK_INTERFACE = 20 +MINOR_21_RESOURCE_CLASS = 21 # When adding another version, update MINOR_MAX_VERSION and also update # doc/source/webapi/v1.rst with a detailed explanation of what the version has # changed. -MINOR_MAX_VERSION = MINOR_20_NETWORK_INTERFACE +MINOR_MAX_VERSION = MINOR_21_RESOURCE_CLASS # String representations of the minor and maximum versions MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/db/sqlalchemy/alembic/versions/dd34e1f1303b_add_resource_class_to_node.py b/ironic/db/sqlalchemy/alembic/versions/dd34e1f1303b_add_resource_class_to_node.py new file mode 100644 index 0000000000..020b3277d7 --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/dd34e1f1303b_add_resource_class_to_node.py @@ -0,0 +1,33 @@ +# 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. + +"""add resource_class to node + +Revision ID: dd34e1f1303b +Revises: 10b163d4481e +Create Date: 2016-07-20 21:48:12.475320 + +""" + +# revision identifiers, used by Alembic. +revision = 'dd34e1f1303b' +down_revision = '10b163d4481e' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('nodes', sa.Column('resource_class', sa.String(80), + nullable=True)) diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index 79b7aa8f2f..a8030dd87b 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -214,6 +214,8 @@ class Connection(api.Connection): query = query.filter_by(maintenance=filters['maintenance']) if 'driver' in filters: query = query.filter_by(driver=filters['driver']) + if 'resource_class' in filters: + query = query.filter_by(resource_class=filters['resource_class']) if 'provision_state' in filters: query = query.filter_by(provision_state=filters['provision_state']) if 'provisioned_before' in filters: diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index 911c01f9ca..46f5c9dc29 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -118,6 +118,7 @@ class Node(Base): driver_info = Column(db_types.JsonEncodedDict) driver_internal_info = Column(db_types.JsonEncodedDict) clean_step = Column(db_types.JsonEncodedDict) + resource_class = Column(String(80), nullable=True) raid_config = Column(db_types.JsonEncodedDict) target_raid_config = Column(db_types.JsonEncodedDict) diff --git a/ironic/objects/node.py b/ironic/objects/node.py index 6077dd2fe3..3d4c5d83dc 100644 --- a/ironic/objects/node.py +++ b/ironic/objects/node.py @@ -47,7 +47,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): # and save() validate the input of property values. # Version 1.15: Add get_by_port_addresses # Version 1.16: Add network_interface field - VERSION = '1.16' + # Version 1.17: Add resource_class field + VERSION = '1.17' dbapi = db_api.get_instance() @@ -99,6 +100,9 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): # that started but failed to finish. 'last_error': object_fields.StringField(nullable=True), + # Used by nova to relate the node to a flavor + 'resource_class': object_fields.StringField(nullable=True), + 'inspection_finished_at': object_fields.DateTimeField(nullable=True), 'inspection_started_at': object_fields.DateTimeField(nullable=True), diff --git a/ironic/tests/unit/api/utils.py b/ironic/tests/unit/api/utils.py index c63ff08076..433d1b54af 100644 --- a/ironic/tests/unit/api/utils.py +++ b/ironic/tests/unit/api/utils.py @@ -94,11 +94,15 @@ def node_post_data(**kw): node.pop('conductor_affinity') node.pop('chassis_id') node.pop('tags') - # NOTE(vdrok): network_interface was introduced in API version 1.20, return - # it only if it was explicitly requested, so that tests using older API - # versions don't fail + + # NOTE(jroll): pop out fields that were introduced in later API versions, + # unless explicitly requested. Otherwise, these will cause tests using + # older API versions to fail. if 'network_interface' not in kw: node.pop('network_interface') + if 'resource_class' not in kw: + node.pop('resource_class') + internal = node_controller.NodePatchType.internal_attrs() return remove_internal(node, internal) diff --git a/ironic/tests/unit/api/v1/test_nodes.py b/ironic/tests/unit/api/v1/test_nodes.py index 5fa6cf2e6d..6250e7193f 100644 --- a/ironic/tests/unit/api/v1/test_nodes.py +++ b/ironic/tests/unit/api/v1/test_nodes.py @@ -111,6 +111,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertNotIn('raid_config', data['nodes'][0]) self.assertNotIn('target_raid_config', data['nodes'][0]) self.assertNotIn('network_interface', data['nodes'][0]) + self.assertNotIn('resource_class', data['nodes'][0]) # never expose the chassis_id self.assertNotIn('chassis_id', data['nodes'][0]) @@ -137,6 +138,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn('clean_step', data) self.assertIn('states', data) self.assertIn('network_interface', data) + self.assertIn('resource_class', data) # never expose the chassis_id self.assertNotIn('chassis_id', data) @@ -336,6 +338,17 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertEqual(node.network_interface, new_data['nodes'][0]["network_interface"]) + def test_hide_fields_in_newer_versions_resource_class(self): + node = obj_utils.create_test_node(self.context, + resource_class='foo') + data = self.get_json( + '/nodes/detail', headers={api_base.Version.string: '1.20'}) + self.assertNotIn('resource_class', data['nodes'][0]) + new_data = self.get_json( + '/nodes/detail', headers={api_base.Version.string: '1.21'}) + self.assertEqual(node.resource_class, + new_data['nodes'][0]["resource_class"]) + def test_many(self): nodes = [] for id in range(5): @@ -756,6 +769,75 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) self.assertTrue(response.json['error_message']) + def _test_get_nodes_by_resource_class(self, detail=False): + if detail: + base_url = '/nodes/detail?resource_class=%s' + else: + base_url = '/nodes?resource_class=%s' + + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + driver='fake', + resource_class='foo') + node1 = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + driver='fake', + resource_class='bar') + + data = self.get_json(base_url % 'foo', + headers={api_base.Version.string: "1.21"}) + uuids = [n['uuid'] for n in data['nodes']] + self.assertIn(node.uuid, uuids) + self.assertNotIn(node1.uuid, uuids) + data = self.get_json(base_url % 'bar', + headers={api_base.Version.string: "1.21"}) + uuids = [n['uuid'] for n in data['nodes']] + self.assertIn(node1.uuid, uuids) + self.assertNotIn(node.uuid, uuids) + + def test_get_nodes_by_resource_class(self): + self._test_get_nodes_by_resource_class(detail=False) + + def test_get_nodes_by_resource_class_detail(self): + self._test_get_nodes_by_resource_class(detail=True) + + def _test_get_nodes_by_invalid_resource_class(self, detail=False): + if detail: + base_url = '/nodes/detail?resource_class=%s' + else: + base_url = '/nodes?resource_class=%s' + + data = self.get_json(base_url % 'test', + headers={api_base.Version.string: "1.21"}) + self.assertEqual(0, len(data['nodes'])) + + def test_get_nodes_by_invalid_resource_class(self): + self._test_get_nodes_by_invalid_resource_class(detail=False) + + def test_get_nodes_by_invalid_resource_class_detail(self): + self._test_get_nodes_by_invalid_resource_class(detail=True) + + def _test_get_nodes_by_resource_class_invalid_api_version(self, + detail=False): + if detail: + base_url = '/nodes/detail?resource_class=%s' + else: + base_url = '/nodes?resource_class=%s' + + response = self.get_json( + base_url % 'fake', + headers={api_base.Version.string: str(api_v1.MIN_VER)}, + expect_errors=True) + self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) + self.assertTrue(response.json['error_message']) + + def test_get_nodes_by_resource_class_invalid_api_version(self): + self._test_get_nodes_by_resource_class_invalid_api_version( + detail=False) + + def test_get_nodes_by_resource_class_invalid_api_version_detail(self): + self._test_get_nodes_by_resource_class_invalid_api_version(detail=True) + def test_get_console_information(self): node = obj_utils.create_test_node(self.context) expected_console_info = {'test': 'test-data'} @@ -1452,6 +1534,64 @@ class TestPatch(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) + def test_update_resource_class(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + self.mock_update_node.return_value = node + resource_class = 'foo' + headers = {api_base.Version.string: '1.21'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/resource_class', + 'value': resource_class, + 'op': 'add'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_update_resource_class_old_api(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + self.mock_update_node.return_value = node + resource_class = 'foo' + headers = {api_base.Version.string: '1.20'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/resource_class', + 'value': resource_class, + 'op': 'add'}], + headers=headers, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) + + def test_update_resource_class_max_length(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + self.mock_update_node.return_value = node + resource_class = 'f' * 80 + headers = {api_base.Version.string: '1.21'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/resource_class', + 'value': resource_class, + 'op': 'add'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_update_resource_class_too_long(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + self.mock_update_node.return_value = node + resource_class = 'f' * 81 + headers = {api_base.Version.string: '1.21'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/resource_class', + 'value': resource_class, + 'op': 'add'}], + headers=headers, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_code) + class TestPost(test_api_base.BaseApiTest): @@ -1793,6 +1933,25 @@ 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_resource_class(self): + ndict = test_api_utils.post_get_test_node( + resource_class='foo') + response = self.post_json('/nodes', ndict, + headers={api_base.Version.string: + str(api_v1.MAX_VER)}) + self.assertEqual(http_client.CREATED, response.status_int) + result = self.get_json('/nodes/%s' % ndict['uuid'], + headers={api_base.Version.string: + str(api_v1.MAX_VER)}) + self.assertEqual('foo', result['resource_class']) + + def test_create_node_resource_class_old_api_version(self): + ndict = test_api_utils.post_get_test_node( + resource_class='foo') + response = self.post_json('/nodes', ndict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int) + class TestDelete(test_api_base.BaseApiTest): diff --git a/ironic/tests/unit/api/v1/test_utils.py b/ironic/tests/unit/api/v1/test_utils.py index 68d896d086..e8e777bce9 100644 --- a/ironic/tests/unit/api/v1/test_utils.py +++ b/ironic/tests/unit/api/v1/test_utils.py @@ -131,21 +131,33 @@ class TestApiUtils(base.TestCase): utils.check_allow_specify_fields, ['foo']) @mock.patch.object(pecan, 'request', spec_set=['version']) - def test_check_allow_specify_network_interface(self, mock_request): + def test_check_allowed_fields_network_interface(self, mock_request): mock_request.version.minor = 20 self.assertIsNone( - utils.check_allow_specify_network_interface_in_fields( - ['network_interface'])) + utils.check_allowed_fields(['network_interface'])) @mock.patch.object(pecan, 'request', spec_set=['version']) - def test_check_allow_specify_network_interface_in_fields_fail( - self, mock_request): + def test_check_allowed_fields_network_interface_fail(self, mock_request): mock_request.version.minor = 19 self.assertRaises( exception.NotAcceptable, - utils.check_allow_specify_network_interface_in_fields, + utils.check_allowed_fields, ['network_interface']) + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allowed_fields_resource_class(self, mock_request): + mock_request.version.minor = 21 + self.assertIsNone( + utils.check_allowed_fields(['resource_class'])) + + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allowed_fields_resource_class_fail(self, mock_request): + mock_request.version.minor = 20 + self.assertRaises( + exception.NotAcceptable, + utils.check_allowed_fields, + ['resource_class']) + @mock.patch.object(pecan, 'request', spec_set=['version']) def test_check_allow_specify_driver(self, mock_request): mock_request.version.minor = 16 @@ -157,6 +169,17 @@ class TestApiUtils(base.TestCase): self.assertRaises(exception.NotAcceptable, utils.check_allow_specify_driver, ['fake']) + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_specify_resource_class(self, mock_request): + mock_request.version.minor = 21 + self.assertIsNone(utils.check_allow_specify_resource_class(['foo'])) + + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_specify_resource_class_fail(self, mock_request): + mock_request.version.minor = 20 + self.assertRaises(exception.NotAcceptable, + utils.check_allow_specify_resource_class, ['foo']) + @mock.patch.object(pecan, 'request', spec_set=['version']) def test_check_allow_manage_verbs(self, mock_request): mock_request.version.minor = 4 @@ -255,6 +278,13 @@ class TestApiUtils(base.TestCase): mock_request.version.minor = 19 self.assertFalse(utils.allow_network_interface()) + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_allow_resource_class(self, mock_request): + mock_request.version.minor = 21 + self.assertTrue(utils.allow_resource_class()) + mock_request.version.minor = 20 + self.assertFalse(utils.allow_resource_class()) + class TestNodeIdent(base.TestCase): diff --git a/ironic/tests/unit/db/sqlalchemy/test_migrations.py b/ironic/tests/unit/db/sqlalchemy/test_migrations.py index 2785d771ee..8b1acf158f 100644 --- a/ironic/tests/unit/db/sqlalchemy/test_migrations.py +++ b/ironic/tests/unit/db/sqlalchemy/test_migrations.py @@ -433,6 +433,13 @@ class MigrationCheckersMixin(object): self.assertIsInstance(portgroups.c.internal_info.type, sqlalchemy.types.TEXT) + def _check_dd34e1f1303b(self, engine, data): + nodes = db_utils.get_table(engine, 'nodes') + col_names = [column.name for column in nodes.c] + self.assertIn('resource_class', col_names) + self.assertIsInstance(nodes.c.resource_class.type, + sqlalchemy.types.String) + def test_upgrade_and_version(self): with patch_with_engine(self.engine): self.migration_api.upgrade('head') diff --git a/ironic/tests/unit/db/test_nodes.py b/ironic/tests/unit/db/test_nodes.py index ddac51db02..9e5f7fbc9b 100644 --- a/ironic/tests/unit/db/test_nodes.py +++ b/ironic/tests/unit/db/test_nodes.py @@ -123,7 +123,8 @@ class DbNodeTestCase(base.DbTestCase): node2 = utils.create_test_node( driver='driver-two', uuid=uuidutils.generate_uuid(), - maintenance=True) + maintenance=True, + resource_class='foo') node3 = utils.create_test_node( driver='driver-one', uuid=uuidutils.generate_uuid(), @@ -157,6 +158,9 @@ class DbNodeTestCase(base.DbTestCase): self.assertEqual(sorted([node1.id, node3.id]), sorted([r.id for r in res])) + res = self.dbapi.get_node_list(filters={'resource_class': 'foo'}) + self.assertEqual([node2.id], [r.id for r in res]) + res = self.dbapi.get_node_list( filters={'reserved_by_any_of': ['fake-host', 'another-fake-host']}) diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index 2e2285180a..2d8b4c9e87 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -226,6 +226,7 @@ def get_test_node(**kw): 'raid_config': kw.get('raid_config'), 'target_raid_config': kw.get('target_raid_config'), 'tags': kw.get('tags', []), + 'resource_class': kw.get('resource_class'), 'network_interface': kw.get('network_interface'), } diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 2a99424a9d..b734031d76 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -404,7 +404,7 @@ class TestObject(_LocalTest, _TestObject): # version bump. It is md5 hash of object fields and remotable methods. # The fingerprint values should only be changed if there is a version bump. expected_object_fingerprints = { - 'Node': '1.16-2a6646627cb937f083f428f5d54e6458', + 'Node': '1.17-ed09e704576dc1b5a74abcbb727bf722', 'MyObj': '1.5-4f5efe8f0fcaf182bbe1c7fe3ba858db', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Port': '1.6-609504503d68982a10f495659990084b', diff --git a/releasenotes/notes/add-node-resource-class-c31e26df4196293e.yaml b/releasenotes/notes/add-node-resource-class-c31e26df4196293e.yaml new file mode 100644 index 0000000000..d52eca3d25 --- /dev/null +++ b/releasenotes/notes/add-node-resource-class-c31e26df4196293e.yaml @@ -0,0 +1,13 @@ +--- +features: + - Adds a `resource_class` field to the node resource, + which will be used by Nova to define which nodes may + quantitatively match a Nova flavor. Operators should + populate this accordingly before deploying the Ocata + version of Nova. +upgrade: + - Adds a `resource_class` field to the node resource, + which will be used by Nova to define which nodes may + quantitatively match a Nova flavor. Operators should + populate this accordingly before deploying the Ocata + version of Nova.