diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index 7a45603ccf..3c6729178c 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,12 @@ REST API Version History ======================== +1.51 (Stein, master) +-------------------- + +Added ``description`` field to the node object to enable operators to store +any information relates to the node. The field is up to 4096 characters. + 1.50 (Stein, master) -------------------- diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index ef98ac988d..03beebb41a 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -110,6 +110,8 @@ ALLOWED_TARGET_POWER_STATES = (ir_states.POWER_ON, ir_states.SOFT_REBOOT, ir_states.SOFT_POWER_OFF) +_NODE_DESCRIPTION_MAX_LENGTH = 4096 + def get_nodes_controller_reserved_names(): global _NODES_CONTROLLER_RESERVED_WORDS @@ -1078,6 +1080,9 @@ class Node(base.APIBase): owner = wsme.wsattr(wtypes.text) """Field for storage of physical node owner""" + description = wsme.wsattr(wtypes.text) + """Field for node description""" + # NOTE(deva): "conductor_affinity" shouldn't be presented on the # API because it's an internal value. Don't add it here. @@ -1603,7 +1608,8 @@ class NodesController(rest.RestController): sort_key, sort_dir, driver=None, resource_class=None, resource_url=None, fields=None, fault=None, conductor_group=None, - detail=None, conductor=None, owner=None): + detail=None, conductor=None, owner=None, + description_contains=None): if self.from_chassis and not chassis_uuid: raise exception.MissingParameterValue( _("Chassis id not specified.")) @@ -1646,6 +1652,7 @@ class NodesController(rest.RestController): 'fault': fault, 'conductor_group': conductor_group, 'owner': owner, + 'description_contains': description_contains, } filters = {} for key, value in possible_filters.items(): @@ -1763,13 +1770,13 @@ class NodesController(rest.RestController): types.boolean, wtypes.text, types.uuid, int, wtypes.text, wtypes.text, wtypes.text, types.listtype, wtypes.text, wtypes.text, wtypes.text, types.boolean, wtypes.text, - wtypes.text) + wtypes.text, 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, resource_class=None, fault=None, conductor_group=None, detail=None, conductor=None, - owner=None): + owner=None, description_contains=None): """Retrieve a list of nodes. :param chassis_uuid: Optional UUID of a chassis, to get only nodes for @@ -1804,6 +1811,9 @@ class NodesController(rest.RestController): :param fields: Optional, a list with a specified set of fields of the resource to be returned. :param fault: Optional string value to get only nodes with that fault. + :param description_contains: Optional string value to get only nodes + with description field contains matching + value. """ cdict = pecan.request.context.to_policy_values() policy.authorize('baremetal:node:get', cdict, cdict) @@ -1822,6 +1832,7 @@ class NodesController(rest.RestController): fields = api_utils.get_request_return_fields(fields, detail, _DEFAULT_RETURN_FIELDS) + extra_args = {'description_contains': description_contains} return self._get_nodes_collection(chassis_uuid, instance_uuid, associated, maintenance, provision_state, marker, @@ -1832,18 +1843,19 @@ class NodesController(rest.RestController): conductor_group=conductor_group, detail=detail, conductor=conductor, - owner=owner) + owner=owner, + **extra_args) @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, 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, resource_class=None, fault=None, conductor_group=None, - conductor=None, owner=None): + conductor=None, owner=None, description_contains=None): """Retrieve a list of nodes with detail. :param chassis_uuid: Optional UUID of a chassis, to get only nodes for @@ -1874,6 +1886,9 @@ class NodesController(rest.RestController): that conductor_group. :param owner: Optional string value that set the owner whose nodes are to be retrurned. + :param description_contains: Optional string value to get only nodes + with description field contains matching + value. """ cdict = pecan.request.context.to_policy_values() policy.authorize('baremetal:node:get', cdict, cdict) @@ -1893,6 +1908,7 @@ class NodesController(rest.RestController): api_utils.check_allow_filter_by_conductor(conductor) resource_url = '/'.join(['nodes', 'detail']) + extra_args = {'description_contains': description_contains} return self._get_nodes_collection(chassis_uuid, instance_uuid, associated, maintenance, provision_state, marker, @@ -1903,7 +1919,8 @@ class NodesController(rest.RestController): fault=fault, conductor_group=conductor_group, conductor=conductor, - owner=owner) + owner=owner, + **extra_args) @METRICS.timer('NodesController.validate') @expose.expose(wtypes.text, types.uuid_or_name, types.uuid) @@ -1984,6 +2001,12 @@ class NodesController(rest.RestController): "creation. These fields can only be set for active nodes") raise exception.Invalid(msg) + if (node.description is not wtypes.Unset and + len(node.description) > _NODE_DESCRIPTION_MAX_LENGTH): + msg = _("Cannot create node with description exceeds %s " + "characters") % _NODE_DESCRIPTION_MAX_LENGTH + raise exception.Invalid(msg) + # NOTE(deva): get_topic_for checks if node.driver is in the hash ring # and raises NoValidHost if it is not. # We need to ensure that node has a UUID before it can @@ -2040,6 +2063,12 @@ class NodesController(rest.RestController): "changing the node's driver.") raise exception.Invalid(msg) + description = api_utils.get_patch_values(patch, '/description') + if description and len(description[0]) > _NODE_DESCRIPTION_MAX_LENGTH: + msg = _("Cannot create node with description exceeds %s " + "characters") % _NODE_DESCRIPTION_MAX_LENGTH + raise exception.Invalid(msg) + @METRICS.timer('NodesController.patch') @wsme.validate(types.uuid, types.boolean, [NodePatchType]) @expose.expose(Node, types.uuid_or_name, types.boolean, diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 4fb56f7ea8..5140774671 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -380,6 +380,7 @@ VERSIONED_FIELDS = { 'protected_reason': versions.MINOR_48_NODE_PROTECTED, 'conductor': versions.MINOR_49_CONDUCTORS, 'owner': versions.MINOR_50_NODE_OWNER, + 'description': versions.MINOR_51_NODE_DESCRIPTION, } for field in V31_FIELDS: diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index fcc83195aa..e28db74635 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -88,6 +88,7 @@ BASE_VERSION = 1 # v1.48: Add protected to the node object. # v1.49: Exposes current conductor on the node object. # v1.50: Add owner to the node object. +# v1.51: Add description to the node object. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -140,6 +141,7 @@ MINOR_47_NODE_AUTOMATED_CLEAN = 47 MINOR_48_NODE_PROTECTED = 48 MINOR_49_CONDUCTORS = 49 MINOR_50_NODE_OWNER = 50 +MINOR_51_NODE_DESCRIPTION = 51 # When adding another version, update: # - MINOR_MAX_VERSION @@ -147,7 +149,7 @@ MINOR_50_NODE_OWNER = 50 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_50_NODE_OWNER +MINOR_MAX_VERSION = MINOR_51_NODE_DESCRIPTION # String representations of the minor and maximum versions _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 38ee7d4f6b..ad8ed48c45 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -131,11 +131,11 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.50', + 'api': '1.51', 'rpc': '1.48', 'objects': { 'Allocation': ['1.0'], - 'Node': ['1.31', '1.30', '1.29', '1.28'], + 'Node': ['1.32', '1.31', '1.30', '1.29', '1.28'], 'Conductor': ['1.3'], 'Chassis': ['1.3'], 'Port': ['1.9'], diff --git a/ironic/db/sqlalchemy/alembic/versions/28c44432c9c3_add_node_description.py b/ironic/db/sqlalchemy/alembic/versions/28c44432c9c3_add_node_description.py new file mode 100644 index 0000000000..3f3b53697b --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/28c44432c9c3_add_node_description.py @@ -0,0 +1,31 @@ +# 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 node description + +Revision ID: 28c44432c9c3 +Revises: dd67b91a1981 +Create Date: 2019-01-23 13:54:08.850421 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '28c44432c9c3' +down_revision = '9cbeefa3763f' + + +def upgrade(): + op.add_column('nodes', sa.Column('description', sa.Text(), + nullable=True)) diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index ff2b97668b..b134325f50 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -225,7 +225,7 @@ class Connection(api.Connection): def __init__(self): pass - def _add_nodes_filters(self, query, filters): + def _validate_nodes_filters(self, filters): if filters is None: filters = dict() supported_filters = {'console_enabled', 'maintenance', 'driver', @@ -233,13 +233,17 @@ class Connection(api.Connection): 'chassis_uuid', 'associated', 'reserved', 'reserved_by_any_of', 'provisioned_before', 'inspection_started_before', 'fault', - 'conductor_group', 'owner', - 'uuid_in', 'with_power_state'} + 'conductor_group', 'owner', 'uuid_in', + 'with_power_state', 'description_contains'} unsupported_filters = set(filters).difference(supported_filters) if unsupported_filters: msg = _("SqlAlchemy API does not support " "filtering by %s") % ', '.join(unsupported_filters) raise ValueError(msg) + return filters + + def _add_nodes_filters(self, query, filters): + filters = self._validate_nodes_filters(filters) for field in ['console_enabled', 'maintenance', 'driver', 'resource_class', 'provision_state', 'uuid', 'id', 'fault', 'conductor_group', 'owner']: @@ -280,6 +284,11 @@ class Connection(api.Connection): query = query.filter(models.Node.power_state != sql.null()) else: query = query.filter(models.Node.power_state == sql.null()) + if 'description_contains' in filters: + keyword = filters['description_contains'] + if keyword is not None: + query = query.filter( + models.Node.description.like(r'%{}%'.format(keyword))) return query diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index ddd300a832..e70fefcc66 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -182,6 +182,7 @@ class Node(Base): owner = Column(String(255), nullable=True) allocation_id = Column(Integer, ForeignKey('allocations.id'), nullable=True) + description = Column(Text, nullable=True) bios_interface = Column(String(255), nullable=True) boot_interface = Column(String(255), nullable=True) diff --git a/ironic/objects/node.py b/ironic/objects/node.py index ffe4e0e667..dfd56e5896 100644 --- a/ironic/objects/node.py +++ b/ironic/objects/node.py @@ -68,7 +68,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): # Version 1.29: Add protected and protected_reason fields # Version 1.30: Add owner field # Version 1.31: Add allocation_id field - VERSION = '1.31' + # Version 1.32: Add description field + VERSION = '1.32' dbapi = db_api.get_instance() @@ -153,6 +154,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): 'vendor_interface': object_fields.StringField(nullable=True), 'traits': object_fields.ObjectField('TraitList', nullable=True), 'owner': object_fields.StringField(nullable=True), + 'description': object_fields.StringField(nullable=True), } def as_dict(self, secure=False): @@ -577,6 +579,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): set to None or removed. Version 1.31: allocation_id was added. For versions prior to this, it should be set to None (or removed). + Version 1.32: description was added. 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 @@ -590,7 +594,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): fields = [('rescue_interface', 22), ('traits', 23), ('bios_interface', 24), ('fault', 25), ('automated_clean', 28), ('protected_reason', 29), - ('owner', 30), ('allocation_id', 31)] + ('owner', 30), ('allocation_id', 31), ('description', 32)] for name, minor in fields: self._adjust_field_to_version(name, None, target_version, 1, minor, remove_unavailable_fields) @@ -622,6 +626,7 @@ class NodePayload(notification.NotificationPayloadBase): 'console_enabled': ('node', 'console_enabled'), 'created_at': ('node', 'created_at'), 'deploy_step': ('node', 'deploy_step'), + 'description': ('node', 'description'), 'driver': ('node', 'driver'), 'extra': ('node', 'extra'), 'inspection_finished_at': ('node', 'inspection_finished_at'), @@ -672,13 +677,15 @@ class NodePayload(notification.NotificationPayloadBase): # Version 1.10: Add conductor_group field exposed via API. # Version 1.11: Add protected and protected_reason fields exposed via API. # Version 1.12: Add node owner field. - VERSION = '1.12' + # Version 1.13: Add description field. + VERSION = '1.13' fields = { 'clean_step': object_fields.FlexibleDictField(nullable=True), 'conductor_group': object_fields.StringField(nullable=True), 'console_enabled': object_fields.BooleanField(nullable=True), 'created_at': object_fields.DateTimeField(nullable=True), 'deploy_step': object_fields.FlexibleDictField(nullable=True), + 'description': object_fields.StringField(nullable=True), 'driver': object_fields.StringField(nullable=True), 'extra': object_fields.FlexibleDictField(nullable=True), 'inspection_finished_at': object_fields.DateTimeField(nullable=True), @@ -754,7 +761,8 @@ class NodeSetPowerStatePayload(NodePayload): # Version 1.10: Parent NodePayload version 1.10 # Version 1.11: Parent NodePayload version 1.11 # Version 1.12: Parent NodePayload version 1.12 - VERSION = '1.12' + # Version 1.13: Parent NodePayload version 1.13 + VERSION = '1.13' fields = { # "to_power" indicates the future target_power_state of the node. A @@ -807,7 +815,8 @@ class NodeCorrectedPowerStatePayload(NodePayload): # Version 1.10: Parent NodePayload version 1.10 # Version 1.11: Parent NodePayload version 1.11 # Version 1.12: Parent NodePayload version 1.12 - VERSION = '1.12' + # Version 1.13: Parent NodePayload version 1.13 + VERSION = '1.13' fields = { 'from_power': object_fields.StringField(nullable=True) @@ -844,7 +853,8 @@ class NodeSetProvisionStatePayload(NodePayload): # Version 1.10: Parent NodePayload version 1.10 # Version 1.11: Parent NodePayload version 1.11 # Version 1.12: Parent NodePayload version 1.12 - VERSION = '1.12' + # Version 1.13: Parent NodePayload version 1.13 + VERSION = '1.13' SCHEMA = dict(NodePayload.SCHEMA, **{'instance_info': ('node', 'instance_info')}) @@ -888,7 +898,8 @@ class NodeCRUDPayload(NodePayload): # Version 1.8: Parent NodePayload version 1.10 # Version 1.9: Parent NodePayload version 1.11 # Version 1.10: Parent NodePayload version 1.12 - VERSION = '1.10' + # Version 1.11: Parent NodePayload version 1.13 + VERSION = '1.11' SCHEMA = dict(NodePayload.SCHEMA, **{'instance_info': ('node', 'instance_info'), diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index f5b717710c..14ee668b24 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -345,6 +345,12 @@ class TestListNodes(test_api_base.BaseApiTest): headers={api_base.Version.string: '1.50'}) self.assertEqual(data['owner'], "akindofmagic") + def test_node_description_null_field(self): + node = obj_utils.create_test_node(self.context, description=None) + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.51'}) + self.assertIsNone(data['description']) + def test_get_one_custom_fields(self): node = obj_utils.create_test_node(self.context, chassis_id=self.chassis.id) @@ -543,6 +549,14 @@ class TestListNodes(test_api_base.BaseApiTest): headers={api_base.Version.string: '1.50'}) self.assertIn('owner', response) + def test_get_description_field(self): + node = obj_utils.create_test_node(self.context, + description='useful piece') + fields = 'description' + response = self.get_json('/nodes/%s?fields=%s' % (node.uuid, fields), + headers={api_base.Version.string: '1.51'}) + self.assertIn('description', response) + def test_detail(self): node = obj_utils.create_test_node(self.context, chassis_id=self.chassis.id) @@ -790,6 +804,17 @@ class TestListNodes(test_api_base.BaseApiTest): '/nodes/detail', headers={api_base.Version.string: '1.37'}) self.assertEqual(['CUSTOM_1'], new_data['nodes'][0]["traits"]) + def test_hide_fields_in_newer_versions_description(self): + node = obj_utils.create_test_node(self.context, + description="useful piece") + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: "1.50"}) + self.assertNotIn('description', data) + + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: "1.51"}) + self.assertEqual('useful piece', data['description']) + def test_many(self): nodes = [] for id in range(5): @@ -1690,6 +1715,25 @@ 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_description(self): + node1 = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + description='some cats here') + node2 = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + description='some dogs there') + data = self.get_json('/nodes?description_contains=cat', + headers={api_base.Version.string: '1.51'}) + uuids = [n['uuid'] for n in data['nodes']] + self.assertIn(node1.uuid, uuids) + self.assertNotIn(node2.uuid, uuids) + + data = self.get_json('/nodes?description_contains=dog', + headers={api_base.Version.string: '1.51'}) + uuids = [n['uuid'] for n in data['nodes']] + self.assertIn(node2.uuid, uuids) + self.assertNotIn(node1.uuid, uuids) + def test_get_console_information(self): node = obj_utils.create_test_node(self.context) expected_console_info = {'test': 'test-data'} @@ -2924,6 +2968,34 @@ 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_description(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + self.mock_update_node.return_value = node + headers = {api_base.Version.string: '1.51'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/description', + 'value': 'meow', + 'op': 'replace'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_update_description_oversize(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + desc = '12345678' * 512 + 'last weed' + self.mock_update_node.return_value = node + headers = {api_base.Version.string: '1.51'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/description', + 'value': desc, + 'op': 'replace'}], + headers=headers, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_code) + def _create_node_locally(node): driver_factory.check_and_update_node_interfaces(node) @@ -3550,6 +3622,27 @@ class TestPost(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int) + def test_create_node_description(self): + node = test_api_utils.post_get_test_node(description='useful stuff') + response = self.post_json('/nodes', node, + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual(http_client.CREATED, response.status_int) + result = self.get_json('/nodes/%s' % node['uuid'], + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual('useful stuff', result['description']) + + def test_create_node_description_oversize(self): + desc = '12345678' * 512 + 'last weed' + node = test_api_utils.post_get_test_node(description=desc) + response = self.post_json('/nodes', node, + headers={api_base.Version.string: + str(api_v1.max_version())}, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + class TestDelete(test_api_base.BaseApiTest): diff --git a/ironic/tests/unit/db/sqlalchemy/test_migrations.py b/ironic/tests/unit/db/sqlalchemy/test_migrations.py index 83b60ba277..765ab4273f 100644 --- a/ironic/tests/unit/db/sqlalchemy/test_migrations.py +++ b/ironic/tests/unit/db/sqlalchemy/test_migrations.py @@ -851,6 +851,13 @@ class MigrationCheckersMixin(object): (sqlalchemy.types.Boolean, sqlalchemy.types.Integer)) + def _check_28c44432c9c3(self, engine, data): + nodes_tbl = db_utils.get_table(engine, 'nodes') + col_names = [column.name for column in nodes_tbl.c] + self.assertIn('description', col_names) + self.assertIsInstance(nodes_tbl.c.description.type, + sqlalchemy.types.TEXT) + 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 f92baa7b14..79002dabc6 100644 --- a/ironic/tests/unit/db/test_nodes.py +++ b/ironic/tests/unit/db/test_nodes.py @@ -273,6 +273,19 @@ class DbNodeTestCase(base.DbTestCase): states.INSPECTING}) self.assertEqual([node2.id], [r[0] for r in res]) + def test_get_nodeinfo_list_description(self): + node1 = utils.create_test_node(uuid=uuidutils.generate_uuid(), + description='Hello') + node2 = utils.create_test_node(uuid=uuidutils.generate_uuid(), + description='World!') + res = self.dbapi.get_nodeinfo_list( + filters={'description_contains': 'Hello'}) + self.assertEqual([node1.id], [r[0] for r in res]) + + res = self.dbapi.get_nodeinfo_list(filters={'description_contains': + 'World!'}) + self.assertEqual([node2.id], [r[0] for r in res]) + def test_get_node_list(self): uuids = [] for i in range(1, 6): @@ -382,6 +395,19 @@ class DbNodeTestCase(base.DbTestCase): self.dbapi.get_node_list, filters=filters) + def test_get_node_list_description(self): + node1 = utils.create_test_node(uuid=uuidutils.generate_uuid(), + description='Hello') + node2 = utils.create_test_node(uuid=uuidutils.generate_uuid(), + description='World!') + res = self.dbapi.get_node_list(filters={ + 'description_contains': 'Hello'}) + self.assertEqual([node1.id], [r.id for r in res]) + + res = self.dbapi.get_node_list(filters={ + 'description_contains': 'World!'}) + self.assertEqual([node2.id], [r.id for r in res]) + def test_get_node_list_chassis_not_found(self): self.assertRaises(exception.ChassisNotFound, self.dbapi.get_node_list, diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index c7f983aa76..40fddf7c60 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -222,6 +222,7 @@ def get_test_node(**kw): 'conductor': kw.get('conductor'), 'owner': kw.get('owner', None), 'allocation_id': kw.get('allocation_id'), + 'description': kw.get('description'), } for iface in drivers_base.ALL_INTERFACES: diff --git a/ironic/tests/unit/objects/test_node.py b/ironic/tests/unit/objects/test_node.py index 6d25cad24e..f8334016f1 100644 --- a/ironic/tests/unit/objects/test_node.py +++ b/ironic/tests/unit/objects/test_node.py @@ -949,6 +949,68 @@ class TestConvertToVersion(db_base.DbTestCase): self.assertIsNone(node.allocation_id) self.assertEqual({}, node.obj_get_changes()) + def test_description_supported_missing(self): + # description not set, should be set to default. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + delattr(node, 'description') + node.obj_reset_changes() + node._convert_to_version("1.32") + self.assertIsNone(node.description) + self.assertEqual({'description': None}, + node.obj_get_changes()) + + def test_description_supported_set(self): + # description set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.description = "Useful information relates to this node" + node.obj_reset_changes() + node._convert_to_version("1.32") + self.assertEqual("Useful information relates to this node", + node.description) + self.assertEqual({}, node.obj_get_changes()) + + def test_description_unsupported_missing(self): + # description not set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + delattr(node, 'description') + node.obj_reset_changes() + node._convert_to_version("1.31") + self.assertNotIn('description', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_description_unsupported_set_remove(self): + # description set, should be removed. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.description = "Useful piece" + node.obj_reset_changes() + node._convert_to_version("1.31") + self.assertNotIn('description', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_description_unsupported_set_no_remove_non_default(self): + # description set, should be set to default. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.description = "Useful piece" + node.obj_reset_changes() + node._convert_to_version("1.31", False) + self.assertIsNone(node.description) + self.assertEqual({'description': None}, + node.obj_get_changes()) + + def test_description_unsupported_set_no_remove_default(self): + # description set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.description = None + node.obj_reset_changes() + node._convert_to_version("1.31", False) + self.assertIsNone(node.description) + 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 f6c32fcf41..020a659d61 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -677,7 +677,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.31-1b77c11e94f971a71c76f5f44fb5b3f4', + 'Node': '1.32-525750e76f07b62142ed5297334b7832', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Port': '1.9-0cb9202a4ec442e8c0d87a324155eaaf', @@ -685,21 +685,21 @@ expected_object_fingerprints = { 'Conductor': '1.3-d3f53e853b4d58cae5bfbd9a8341af4a', 'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370', 'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d', - 'NodePayload': '1.12-7d650c2a024357275990681f020512e4', + 'NodePayload': '1.13-18a34d461ef7d5dbc1c3e5a55fcb867a', 'NodeSetPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeSetPowerStatePayload': '1.12-703d110d571cc95b2947bb6bd153fcb8', + 'NodeSetPowerStatePayload': '1.13-4f96e52568e058e3fd6ffc9b0cf15764', 'NodeCorrectedPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeCorrectedPowerStatePayload': '1.12-29cbb6b20a0aeea9e0ab9e17302e9e16', + 'NodeCorrectedPowerStatePayload': '1.13-929af354e7c3474520ce6162ee794717', 'NodeSetProvisionStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeSetProvisionStatePayload': '1.12-a302ce357ad39a0a4d1ca3c0ee44f0e0', + 'NodeSetProvisionStatePayload': '1.13-fa15d2954961d8edcaba9d737a1cad91', 'VolumeConnector': '1.0-3e0252c0ab6e6b9d158d09238a577d97', 'VolumeTarget': '1.0-0b10d663d8dae675900b2c7548f76f5e', 'ChassisCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'ChassisCRUDPayload': '1.0-dce63895d8186279a7dd577cffccb202', 'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeCRUDPayload': '1.10-49590dee863c5ed1193f5deae0a0a2f2', + 'NodeCRUDPayload': '1.11-f1c6a6b099e8e28f55378c448c033de0', 'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'PortCRUDPayload': '1.3-21235916ed54a91b2a122f59571194e7', 'NodeMaintenanceNotification': '1.0-59acc533c11d306f149846f922739c15', diff --git a/releasenotes/notes/add-node-description-790097704f45af91.yaml b/releasenotes/notes/add-node-description-790097704f45af91.yaml new file mode 100644 index 0000000000..fa338b325f --- /dev/null +++ b/releasenotes/notes/add-node-description-790097704f45af91.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Adds a ``description`` field to the node object to enable operators to + store any information relates to the node. The field is up to 4096 + characters.