summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKaifeng Wang <kaifeng.w@gmail.com>2019-01-23 17:07:03 +0800
committerKaifeng Wang <kaifeng.w@gmail.com>2019-02-01 10:25:58 +0800
commitd30d8149564260bfe219fbefb6b13d2817ea592f (patch)
tree60d635529a260ddf20be2411fd124d5e3360f8a7
parent680e5b5687881589a79e7238d3f0281d4c9d1a13 (diff)
downloadironic-d30d8149564260bfe219fbefb6b13d2817ea592f.tar.gz
Add description field to node
This patch implements the feature of storing informational free-form text into ironic node, via the "description" field. Operators can do simple queries on the context of description. Change-Id: I787fb0df34566aff30dea4c4a3ba0e1ec820d044 Story: 2003089 Task: 23178
-rw-r--r--doc/source/contributor/webapi-version-history.rst6
-rw-r--r--ironic/api/controllers/v1/node.py43
-rw-r--r--ironic/api/controllers/v1/utils.py1
-rw-r--r--ironic/api/controllers/v1/versions.py4
-rw-r--r--ironic/common/release_mappings.py4
-rw-r--r--ironic/db/sqlalchemy/alembic/versions/28c44432c9c3_add_node_description.py31
-rw-r--r--ironic/db/sqlalchemy/api.py15
-rw-r--r--ironic/db/sqlalchemy/models.py1
-rw-r--r--ironic/objects/node.py25
-rw-r--r--ironic/tests/unit/api/controllers/v1/test_node.py93
-rw-r--r--ironic/tests/unit/db/sqlalchemy/test_migrations.py7
-rw-r--r--ironic/tests/unit/db/test_nodes.py26
-rw-r--r--ironic/tests/unit/db/utils.py1
-rw-r--r--ironic/tests/unit/objects/test_node.py62
-rw-r--r--ironic/tests/unit/objects/test_objects.py12
-rw-r--r--releasenotes/notes/add-node-description-790097704f45af91.yaml6
16 files changed, 311 insertions, 26 deletions
diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst
index 7a45603cc..3c6729178 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 ef98ac988..03beebb41 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 4fb56f7ea..514077467 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 fcc83195a..e28db7463 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 38ee7d4f6..ad8ed48c4 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 000000000..3f3b53697
--- /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 ff2b97668..b134325f5 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 ddd300a83..e70fefcc6 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 ffe4e0e66..dfd56e589 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 f5b717710..14ee668b2 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 83b60ba27..765ab4273 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 f92baa7b1..79002dabc 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 c7f983aa7..40fddf7c6 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 6d25cad24..f8334016f 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 f6c32fcf4..020a659d6 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 000000000..fa338b325
--- /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.