diff --git a/api-ref/source/samples/port-create-request.json b/api-ref/source/samples/port-create-request.json index 5c2988c783..cf3a4dd219 100644 --- a/api-ref/source/samples/port-create-request.json +++ b/api-ref/source/samples/port-create-request.json @@ -2,6 +2,7 @@ "node_ident": "6d85703a-565d-469a-96ce-30b6de53079d", "portgroup_uuid": "e43c722c-248e-4c6e-8ce8-0d8ff129387a", "name": "port1", + "description": "Physical Network", "address": "11:11:11:11:11:11", "is_smartnic": true, "local_link_connection": { diff --git a/api-ref/source/samples/port-create-response.json b/api-ref/source/samples/port-create-response.json index 7fb778c231..9436650040 100644 --- a/api-ref/source/samples/port-create-response.json +++ b/api-ref/source/samples/port-create-response.json @@ -20,6 +20,7 @@ "switch_info": "switch1" }, "name": "port1", + "description": "Physical Network", "node_uuid": "6d85703a-565d-469a-96ce-30b6de53079d", "physical_network": "physnet1", "portgroup_uuid": "e43c722c-248e-4c6e-8ce8-0d8ff129387a", diff --git a/api-ref/source/samples/port-list-detail-response.json b/api-ref/source/samples/port-list-detail-response.json index cca146232b..405707b1c0 100644 --- a/api-ref/source/samples/port-list-detail-response.json +++ b/api-ref/source/samples/port-list-detail-response.json @@ -22,6 +22,7 @@ "switch_info": "switch1" }, "name": "port1", + "description": "Physical Network", "node_uuid": "6d85703a-565d-469a-96ce-30b6de53079d", "physical_network": "physnet1", "portgroup_uuid": "e43c722c-248e-4c6e-8ce8-0d8ff129387a", diff --git a/api-ref/source/samples/port-update-response.json b/api-ref/source/samples/port-update-response.json index a62332222d..e2eb622a6a 100644 --- a/api-ref/source/samples/port-update-response.json +++ b/api-ref/source/samples/port-update-response.json @@ -20,6 +20,7 @@ "switch_info": "switch1" }, "name": "port1", + "description": "Physical Network", "node_uuid": "6d85703a-565d-469a-96ce-30b6de53079d", "physical_network": "physnet1", "portgroup_uuid": "e43c722c-248e-4c6e-8ce8-0d8ff129387a", diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index 79c3e03810..8576e3c3fc 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,11 @@ REST API Version History ======================== +1.97 (Flamingo) +----------------------- + +Add a 'description' field to the Port object. + 1.95 (Epoxy) ----------------------- diff --git a/ironic/api/controllers/v1/port.py b/ironic/api/controllers/v1/port.py index f70c24093d..3a33c623a7 100644 --- a/ironic/api/controllers/v1/port.py +++ b/ironic/api/controllers/v1/port.py @@ -53,6 +53,7 @@ PORT_SCHEMA = { 'pxe_enabled': {'type': ['string', 'boolean', 'null']}, 'uuid': {'type': ['string', 'null']}, 'name': {'type': ['string', 'null']}, + 'description': {'type': ['string', 'null'], 'maxLength': 255}, }, 'required': ['address'], 'oneOf': [ @@ -76,6 +77,7 @@ PATCH_ALLOWED_FIELDS = [ 'portgroup_uuid', 'pxe_enabled', 'name', + 'description', ] PORT_VALIDATOR_EXTRA = args.dict_valid( @@ -134,6 +136,9 @@ def hide_fields_in_newer_versions(port): # expect the key port.local_link_connection to exist even if we # cannot set a valid value port['local_link_connection'] = {} + # if requested version is < 1.97, hide description field. + if not api_utils.allow_port_description(): + port.pop('description', None) def convert_with_links(rpc_port, fields=None, sanitize=True): @@ -150,6 +155,7 @@ def convert_with_links(rpc_port, fields=None, sanitize=True): 'pxe_enabled', 'node_uuid', 'name', + 'description', ) ) if rpc_port.portgroup_id: @@ -227,7 +233,7 @@ class PortsController(rest.RestController): def _get_ports_collection(self, node_ident, address, portgroup_ident, shard, marker, limit, sort_key, sort_dir, resource_url=None, fields=None, detail=None, - project=None): + project=None, description_contains=None): """Retrieve a collection of ports. :param node_ident: UUID or name of a node, to get only ports for that @@ -250,6 +256,9 @@ class PortsController(rest.RestController): of the resource to be returned. :param detail: Optional, show detailed list of ports :param project: Optional, filter by project + :param description_contains: Optional string value to get only ports + with description field contains matching + value. :returns: a list of ports. """ @@ -277,6 +286,10 @@ class PortsController(rest.RestController): if exclusive_filters > 1: raise exception.OperationNotPermitted() + filters = {} + if description_contains: + filters['description_contains'] = description_contains + if portgroup_ident: # FIXME: Since all we need is the portgroup ID, we can # make this more efficient by only querying @@ -288,7 +301,8 @@ class PortsController(rest.RestController): marker_obj, sort_key=sort_key, sort_dir=sort_dir, - project=project) + project=project, + filters=filters) elif node_ident: # FIXME(comstud): Since all we need is the node ID, we can # make this more efficient by only querying @@ -299,18 +313,21 @@ class PortsController(rest.RestController): node.id, limit, marker_obj, sort_key=sort_key, sort_dir=sort_dir, - project=project) + project=project, + filters=filters) elif address: ports = self._get_ports_by_address(address, project=project) elif shard: ports = objects.Port.list_by_node_shards(api.request.context, shard, limit, marker_obj, sort_key, - sort_dir, project=project) + sort_dir, project=project, + filters=filters) else: ports = objects.Port.list(api.request.context, limit, marker_obj, sort_key=sort_key, - sort_dir=sort_dir, project=project) + sort_dir=sort_dir, project=project, + filters=filters) parameters = {} if detail is not None: @@ -377,6 +394,9 @@ class PortsController(rest.RestController): if ('name' in fields and not api_utils.allow_port_name()): raise exception.NotAcceptable() + if ('description' in fields + and not api_utils.allow_port_description()): + raise exception.NotAcceptable() @METRICS.timer('PortsController.get_all') @method.expose() @@ -385,10 +405,11 @@ class PortsController(rest.RestController): limit=args.integer, sort_key=args.string, sort_dir=args.string, fields=args.string_list, portgroup=args.uuid_or_name, detail=args.boolean, - shard=args.string_list) + shard=args.string_list, description_contains=args.string) def get_all(self, node=None, node_uuid=None, address=None, marker=None, limit=None, sort_key='id', sort_dir='asc', fields=None, - portgroup=None, detail=None, shard=None): + portgroup=None, detail=None, shard=None, + description_contains=None): """Retrieve a list of ports. Note that the 'node_uuid' interface is deprecated in favour @@ -413,6 +434,9 @@ class PortsController(rest.RestController): for that portgroup. :param shard: Optional, a list of shard ids to filter by, only ports associated with nodes in these shards will be returned. + :param description_contains: Optional string value to get only ports + with description field contains matching + value. :raises: NotAcceptable, HTTPNotFound """ project = api_utils.check_port_list_policy( @@ -437,6 +461,8 @@ class PortsController(rest.RestController): fields = api_utils.get_request_return_fields(fields, detail, _DEFAULT_RETURN_FIELDS) + extra_args = {'description_contains': description_contains} + if not node_uuid and node: # We're invoking this interface using positional notation, or # explicitly using 'node'. Try and determine which one. @@ -450,7 +476,7 @@ class PortsController(rest.RestController): sort_key, sort_dir, resource_url='ports', fields=fields, detail=detail, - project=project) + project=project, **extra_args) @METRICS.timer('PortsController.detail') @method.expose() @@ -458,10 +484,10 @@ class PortsController(rest.RestController): address=args.mac_address, marker=args.uuid, limit=args.integer, sort_key=args.string, sort_dir=args.string, portgroup=args.uuid_or_name, - shard=args.string_list) + shard=args.string_list, description_contains=args.string) def detail(self, node=None, node_uuid=None, address=None, marker=None, limit=None, sort_key='id', sort_dir='asc', portgroup=None, - shard=None): + shard=None, description_contains=None): """Retrieve a list of ports with detail. Note that the 'node_uuid' interface is deprecated in favour @@ -484,6 +510,9 @@ class PortsController(rest.RestController): max_limit resources will be returned. :param sort_key: column to sort results by. Default: id. :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + :param description_contains: Optional string value to get only ports + with description field contains matching + value. :raises: NotAcceptable, HTTPNotFound """ project = api_utils.check_port_list_policy( @@ -509,11 +538,12 @@ class PortsController(rest.RestController): if parent != "ports": raise exception.HTTPNotFound() + extra_args = {'description_contains': description_contains} return self._get_ports_collection(node_uuid or node, address, portgroup, shard, marker, limit, sort_key, sort_dir, resource_url='ports/detail', - project=project) + project=project, **extra_args) @METRICS.timer('PortsController.get_one') @method.expose() diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 244940ab48..61397d385b 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -2228,3 +2228,11 @@ def allow_get_vmedia(): def allow_node_ident_as_param_for_port_creation(): """Check if 'node_ident' parameter is allowed for port creation.""" return api.request.version.minor >= versions.MINOR_94_PORT_NODENAME + + +def allow_port_description(): + """Check if description is allowed for ports. + + Version 1.97 of the API added description field to the port object. + """ + return api.request.version.minor >= versions.MINOR_97_PORT_DESCRIPTION diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 46673b7820..e8259ae649 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -134,6 +134,7 @@ BASE_VERSION = 1 # v1.94: Add node name support for port creation # v1.95: Add node support for disable_power_off # v1.96: Migrate inspection rules from Inspector +# v1.97: Add description field to port. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -232,6 +233,7 @@ MINOR_93_GET_VMEDIA = 93 MINOR_94_PORT_NODENAME = 94 MINOR_95_DISABLE_POWER_OFF = 95 MINOR_96_INSPECTION_RULES = 96 +MINOR_97_PORT_DESCRIPTION = 97 # When adding another version, update: # - MINOR_MAX_VERSION @@ -239,7 +241,7 @@ MINOR_96_INSPECTION_RULES = 96 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_96_INSPECTION_RULES +MINOR_MAX_VERSION = MINOR_97_PORT_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 bac4e56c85..b39f6e9891 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -848,7 +848,7 @@ RELEASE_MAPPING = { # make it below. To release, we will preserve a version matching # the release as a separate block of text, like above. 'master': { - 'api': '1.96', + 'api': '1.97', 'rpc': '1.61', 'objects': { 'Allocation': ['1.1'], @@ -860,7 +860,7 @@ RELEASE_MAPPING = { 'Chassis': ['1.3'], 'Deployment': ['1.0'], 'DeployTemplate': ['1.1'], - 'Port': ['1.11'], + 'Port': ['1.12'], 'Portgroup': ['1.5'], 'Trait': ['1.0'], 'TraitList': ['1.0'], diff --git a/ironic/db/api.py b/ironic/db/api.py index 77e109ced9..08c1a561b0 100644 --- a/ironic/db/api.py +++ b/ironic/db/api.py @@ -285,7 +285,7 @@ class Connection(object, metaclass=abc.ABCMeta): @abc.abstractmethod def get_port_list(self, limit=None, marker=None, - sort_key=None, sort_dir=None): + sort_key=None, sort_dir=None, filters=None): """Return a list of ports. :param limit: Maximum number of ports to return. @@ -294,11 +294,12 @@ class Connection(object, metaclass=abc.ABCMeta): :param sort_key: Attribute by which results should be sorted. :param sort_dir: direction in which results should be sorted. (asc, desc) + :param filters: Filters to apply, defaults to None """ @abc.abstractmethod def get_ports_by_shards(self, shards, limit=None, marker=None, - sort_key=None, sort_dir=None): + sort_key=None, sort_dir=None, filters=None): """Return a list of ports contained in the provided shards. :param shard_ids: A list of shards to filter ports by. @@ -306,7 +307,7 @@ class Connection(object, metaclass=abc.ABCMeta): @abc.abstractmethod def get_ports_by_node_id(self, node_id, limit=None, marker=None, - sort_key=None, sort_dir=None): + sort_key=None, sort_dir=None, filters=None): """List all the ports for a given node. :param node_id: The integer node ID. @@ -316,12 +317,13 @@ class Connection(object, metaclass=abc.ABCMeta): :param sort_key: Attribute by which results should be sorted :param sort_dir: direction in which results should be sorted (asc, desc) + :param filters: Filters to apply, defaults to None :returns: A list of ports. """ @abc.abstractmethod def get_ports_by_portgroup_id(self, portgroup_id, limit=None, marker=None, - sort_key=None, sort_dir=None): + sort_key=None, sort_dir=None, filters=None): """List all the ports for a given portgroup. :param portgroup_id: The integer portgroup ID. @@ -331,6 +333,7 @@ class Connection(object, metaclass=abc.ABCMeta): :param sort_key: Attribute by which results should be sorted :param sort_dir: Direction in which results should be sorted (asc, desc) + :param filters: Filters to apply, defaults to None :returns: A list of ports. """ diff --git a/ironic/db/sqlalchemy/alembic/versions/1c14278d6e33_port_description.py b/ironic/db/sqlalchemy/alembic/versions/1c14278d6e33_port_description.py new file mode 100644 index 0000000000..b17c178499 --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/1c14278d6e33_port_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. + +"""port description + +Revision ID: 1c14278d6e33 +Revises: 21c48150dea9 +Create Date: 2025-03-17 17:12:27.160796 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '1c14278d6e33' +down_revision = '21c48150dea9' + + +def upgrade(): + op.add_column('ports', sa.Column('description', sa.String(length=255), + nullable=True)) diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index be0af10fce..f4708dc9e1 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -250,6 +250,16 @@ def add_port_filter(query, value): return add_identity_filter(query, value) +def add_port_filter_description_contains(query, filters): + filters = filters or {} + if 'description_contains' in filters: + keyword = filters['description_contains'] + if keyword is not None: + query = query.filter( + models.Port.description.like(r'%{}%'.format(keyword))) + return query + + def add_port_filter_by_node(query, value): if strutils.is_int_like(value): return query.filter_by(node_id=value) @@ -1070,45 +1080,49 @@ class Connection(api.Connection): def get_port_list(self, limit=None, marker=None, sort_key=None, sort_dir=None, owner=None, - project=None): + project=None, filters=None): query = sa.select(models.Port) if owner: query = add_port_filter_by_node_owner(query, owner) elif project: query = add_port_filter_by_node_project(query, project) + query = add_port_filter_description_contains(query, filters) return _paginate_query(models.Port, limit, marker, sort_key, sort_dir, query) def get_ports_by_shards(self, shards, limit=None, marker=None, - sort_key=None, sort_dir=None): + sort_key=None, sort_dir=None, filters=None): shard_node_ids = sa.select(models.Node) \ .where(models.Node.shard.in_(shards)) \ .with_only_columns(models.Node.id) query = sa.select(models.Port) \ .where(models.Port.node_id.in_(shard_node_ids)) + query = add_port_filter_description_contains(query, filters) return _paginate_query( models.Port, limit, marker, sort_key, sort_dir, query) def get_ports_by_node_id(self, node_id, limit=None, marker=None, sort_key=None, sort_dir=None, owner=None, - project=None): + project=None, filters=None): query = sa.select(models.Port).where(models.Port.node_id == node_id) if owner: query = add_port_filter_by_node_owner(query, owner) elif project: query = add_port_filter_by_node_project(query, project) + query = add_port_filter_description_contains(query, filters) return _paginate_query(models.Port, limit, marker, sort_key, sort_dir, query) def get_ports_by_portgroup_id(self, portgroup_id, limit=None, marker=None, sort_key=None, sort_dir=None, owner=None, - project=None): + project=None, filters=None): query = sa.select(models.Port).where( models.Port.portgroup_id == portgroup_id) if owner: query = add_port_filter_by_node_owner(query, owner) elif project: query = add_port_filter_by_node_project(query, project) + query = add_port_filter_description_contains(query, filters) return _paginate_query(models.Port, limit, marker, sort_key, sort_dir, query) diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index 155f8fbb63..e5ecc8fc58 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -269,6 +269,7 @@ class Port(Base): physical_network = Column(String(64), nullable=True) is_smartnic = Column(Boolean, nullable=True, default=False) name = Column(String(255), nullable=True) + description = Column(String(255), nullable=True) _node_uuid = orm.relationship( "Node", diff --git a/ironic/objects/port.py b/ironic/objects/port.py index d961a6e3eb..092b37416e 100644 --- a/ironic/objects/port.py +++ b/ironic/objects/port.py @@ -45,7 +45,8 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): # Version 1.9: Add support for Smart NIC port # Version 1.10: Add name field # Version 1.11: Add node_uuid field - VERSION = '1.11' + # Version 1.12: Add description field + VERSION = '1.12' dbapi = dbapi.get_instance() @@ -65,24 +66,36 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): 'is_smartnic': object_fields.BooleanField(nullable=True, default=False), 'name': object_fields.StringField(nullable=True), + 'description': object_fields.StringField(nullable=True), } - def _convert_name_field(self, target_version, - remove_unavailable_fields=True): - name_is_set = self.obj_attr_is_set('name') - if target_version >= (1, 10): - # Target version supports name. Set it to its default + def _convert_field_by_version(self, field_name, introduced_version, + target_version, + remove_unavailable_fields=True, + default_value=None): + """Convert a field based on version compatibility. + + :param field_name: Name of the field to convert + :param introduced_version: Version tuple when the field was introduced + :param target_version: Target version to convert to + :param remove_unavailable_fields: Whether to remove fields not in + target version + :param default_value: Default value to set if field is not set + """ + field_is_set = self.obj_attr_is_set(field_name) + if target_version >= introduced_version: + # Target version supports this field. Set it to its default # value if it is not set. - if not name_is_set: - self.name = None - elif name_is_set: - # Target version does not support name, and it is set. + if not field_is_set: + setattr(self, field_name, default_value) + elif field_is_set: + # Target version does not support this field, and it is set. if remove_unavailable_fields: # (De)serialising: remove unavailable fields. - delattr(self, 'name') - elif self.name is not None: + delattr(self, field_name) + elif getattr(self, field_name) is not default_value: # DB: set unavailable fields to their default. - self.name = None + setattr(self, field_name, default_value) def _convert_to_version(self, target_version, remove_unavailable_fields=True): @@ -126,41 +139,18 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): self.internal_info = internal_info # Convert the physical_network field. - physnet_is_set = self.obj_attr_is_set('physical_network') - if target_version >= (1, 7): - # Target version supports physical_network. Set it to its default - # value if it is not set. - if not physnet_is_set: - self.physical_network = None - elif physnet_is_set: - # Target version does not support physical_network, and it is set. - if remove_unavailable_fields: - # (De)serialising: remove unavailable fields. - delattr(self, 'physical_network') - elif self.physical_network is not None: - # DB: set unavailable fields to their default. - self.physical_network = None - + self._convert_field_by_version('physical_network', (1, 7), + target_version, + remove_unavailable_fields) # Convert is_smartnic field. - is_smartnic_set = self.obj_attr_is_set('is_smartnic') - if target_version >= (1, 9): - # Target version supports is_smartnic. Set it to its default - # value if it is not set. - if not is_smartnic_set: - self.is_smartnic = False - - # handle is_smartnic field in older version - elif is_smartnic_set: - # Target version does not support is_smartnic, and it is set. - if remove_unavailable_fields: - # (De)serialising: remove unavailable fields. - delattr(self, 'is_smartnic') - elif self.is_smartnic is not False: - # DB: set unavailable fields to their default. - self.is_smartnic = False - + self._convert_field_by_version('is_smartnic', (1, 9), target_version, + remove_unavailable_fields, False) # Convert the name field. - self._convert_name_field(target_version, remove_unavailable_fields) + self._convert_field_by_version('name', (1, 10), target_version, + remove_unavailable_fields) + # Convert the description field. + self._convert_field_by_version('description', (1, 12), target_version, + remove_unavailable_fields) # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable # methods can be used in the future to replace current explicit RPC calls. @@ -275,8 +265,8 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): # Implications of calling new remote procedures should be thought through. # @object_base.remotable_classmethod @classmethod - def list(cls, context, limit=None, marker=None, - sort_key=None, sort_dir=None, owner=None, project=None): + def list(cls, context, limit=None, marker=None, sort_key=None, + sort_dir=None, owner=None, project=None, filters=None): """Return a list of Port objects. :param context: Security context. @@ -286,6 +276,7 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): :param sort_dir: direction to sort. "asc" or "desc". :param owner: DEPRECATED a node owner to match against :param project: a node owner or lessee to match against + :param filters: Filters to apply, defaults to None :returns: a list of :class:`Port` object. :raises: InvalidParameterValue @@ -296,12 +287,14 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): marker=marker, sort_key=sort_key, sort_dir=sort_dir, - project=project) + project=project, + filters=filters) return cls._from_db_object_list(context, db_ports) @classmethod def list_by_node_shards(cls, context, shards, limit=None, marker=None, - sort_key=None, sort_dir=None, project=None): + sort_key=None, sort_dir=None, project=None, + filters=None): """Return a list of Port objects associated with nodes in shards :param context: Security context. @@ -311,13 +304,15 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): :param sort_key: column to sort results by. :param sort_dir: direction to sort. "asc" or "desc". :param project: a node owner or lessee to match against + :param filters: Filters to apply, defaults to None :returns: a list of :class:`Port` object. """ db_ports = cls.dbapi.get_ports_by_shards(shards, limit=limit, marker=marker, sort_key=sort_key, - sort_dir=sort_dir) + sort_dir=sort_dir, + filters=filters) return cls._from_db_object_list(context, db_ports) # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable @@ -327,7 +322,7 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): @classmethod def list_by_node_id(cls, context, node_id, limit=None, marker=None, sort_key=None, sort_dir=None, owner=None, - project=None): + project=None, filters=None): """Return a list of Port objects associated with a given node ID. :param context: Security context. @@ -338,6 +333,7 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): :param sort_dir: direction to sort. "asc" or "desc". :param owner: DEPRECATED a node owner to match against :param project: a node owner or lessee to match against + :param filters: Filters to apply, defaults to None :returns: a list of :class:`Port` object. """ @@ -347,7 +343,8 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): marker=marker, sort_key=sort_key, sort_dir=sort_dir, - project=project) + project=project, + filters=filters) return cls._from_db_object_list(context, db_ports) # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable @@ -357,7 +354,7 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): @classmethod def list_by_portgroup_id(cls, context, portgroup_id, limit=None, marker=None, sort_key=None, sort_dir=None, - owner=None, project=None): + owner=None, project=None, filters=None): """Return a list of Port objects associated with a given portgroup ID. :param context: Security context. @@ -368,6 +365,7 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): :param sort_dir: direction to sort. "asc" or "desc". :param owner: DEPRECATED a node owner to match against :param project: a node owner or lessee to match against + :param filters: Filters to apply, defaults to None :returns: a list of :class:`Port` object. """ @@ -378,7 +376,8 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): marker=marker, sort_key=sort_key, sort_dir=sort_dir, - project=project) + project=project, + filters=filters) return cls._from_db_object_list(context, db_ports) # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable @@ -522,7 +521,8 @@ class PortCRUDPayload(notification.NotificationPayloadBase): # Version 1.2: Add "physical_network" field # Version 1.3: Add "is_smartnic" field # Version 1.4: Add "name" field - VERSION = '1.4' + # Version 1.5: Add "description" field + VERSION = '1.5' SCHEMA = { 'address': ('port', 'address'), @@ -535,6 +535,7 @@ class PortCRUDPayload(notification.NotificationPayloadBase): 'uuid': ('port', 'uuid'), 'is_smartnic': ('port', 'is_smartnic'), 'name': ('port', 'name'), + 'description': ('port', 'description'), } fields = { @@ -552,6 +553,7 @@ class PortCRUDPayload(notification.NotificationPayloadBase): 'is_smartnic': object_fields.BooleanField(nullable=True, default=False), 'name': object_fields.StringField(nullable=True), + 'description': object_fields.StringField(nullable=True), } def __init__(self, port, node_uuid, portgroup_uuid): diff --git a/ironic/tests/unit/api/controllers/v1/test_port.py b/ironic/tests/unit/api/controllers/v1/test_port.py index 85edf9b178..2f56d12a69 100644 --- a/ironic/tests/unit/api/controllers/v1/test_port.py +++ b/ironic/tests/unit/api/controllers/v1/test_port.py @@ -197,7 +197,7 @@ class TestPortsController__GetPortsCollection(base.TestCase): resource_url='ports') mock_list.assert_called_once_with('fake-context', 1000, None, project=None, sort_dir='asc', - sort_key=None) + sort_key=None, filters={}) @mock.patch.object(objects.Port, 'get_by_address', autospec=True) @@ -298,12 +298,13 @@ class TestListPorts(test_api_base.BaseApiTest): def test_get_one_custom_fields(self): port = obj_utils.create_test_port(self.context, node_id=self.node.id) - fields = 'address,extra' + fields = 'address,extra,description' data = self.get_json( '/ports/%s?fields=%s' % (port.uuid, fields), headers={api_base.Version.string: str(api_v1.max_version())}) # We always append "links" - self.assertCountEqual(['address', 'extra', 'links'], data) + self.assertCountEqual(['address', 'extra', 'description', 'links'], + data) def test_hide_fields_in_newer_versions_internal_info(self): port = obj_utils.create_test_port(self.context, node_id=self.node.id, @@ -1108,6 +1109,31 @@ class TestListPorts(test_api_base.BaseApiTest): self.assertIn('Expected UUID or name for portgroup', response.json['error_message']) + def test_get_ports_by_description(self): + port1 = obj_utils.create_test_port(self.context, + address='52:54:00:cf:2d:31', + node_id=self.node.id, + uuid=uuidutils.generate_uuid(), + description='some cats here') + port2 = obj_utils.create_test_port(self.context, + node_id=self.node.id, + address='52:54:00:cf:2d:32', + uuid=uuidutils.generate_uuid(), + description='some dogs there') + data = self.get_json( + '/ports?description_contains=cat', + headers={api_base.Version.string: str(api_v1.max_version())}) + uuids = [n['uuid'] for n in data['ports']] + self.assertIn(port1.uuid, uuids) + self.assertNotIn(port2.uuid, uuids) + + data = self.get_json( + '/ports?description_contains=dog', + headers={api_base.Version.string: str(api_v1.max_version())}) + uuids = [n['uuid'] for n in data['ports']] + self.assertIn(port2.uuid, uuids) + self.assertNotIn(port1.uuid, uuids) + class TestListPortsByShard(test_api_base.BaseApiTest): def setUp(self): @@ -1920,6 +1946,7 @@ class TestPost(test_api_base.BaseApiTest): pdict.pop('is_smartnic') pdict.pop('portgroup_uuid') pdict.pop('name') + pdict.pop('description') headers = {api_base.Version.string: str(api_v1.min_version())} response = self.post_json('/ports', pdict, headers=headers) self.assertEqual('application/json', response.content_type) diff --git a/ironic/tests/unit/db/test_ports.py b/ironic/tests/unit/db/test_ports.py index 0284ee0d00..54901d4390 100644 --- a/ironic/tests/unit/db/test_ports.py +++ b/ironic/tests/unit/db/test_ports.py @@ -283,3 +283,29 @@ class DbPortTestCase(base.DbTestCase): uuid=self.port.uuid, node_id=self.node.id, address='aa-bb-cc-33-11-22') + + def test_create_port_with_description(self): + description = 'Management Port' + port1 = db_utils.create_test_port( + uuid=uuidutils.generate_uuid(), + node_id=self.node.id, + address='52:54:00:cf:2d:42', + description=description) + + port2 = db_utils.create_test_port( + uuid=uuidutils.generate_uuid(), + node_id=self.node.id, + address='52:54:00:cf:2d:45', + description=description) + + self.assertEqual(description, port1.description) + self.assertEqual(description, port2.description) + + new_description = 'Updated Description' + updated_port = self.dbapi.update_port( + port1.id, {'description': new_description}) + + self.assertEqual(new_description, updated_port.description) + + retrieved_port1 = self.dbapi.get_port_by_uuid(port1.uuid) + self.assertEqual(new_description, retrieved_port1.description) diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index cbc1775d0d..25b730bf35 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -288,6 +288,7 @@ def get_test_port(**kw): 'physical_network': kw.get('physical_network'), 'is_smartnic': kw.get('is_smartnic', False), 'name': kw.get('name'), + 'description': kw.get('description'), } diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 1359b1a63b..7fe706853a 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -678,7 +678,7 @@ expected_object_fingerprints = { 'Node': '1.41-baff7b2b06243d97448b720030b2e612', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', - 'Port': '1.11-97bf15b61224f26c65e90f007d78bfd2', + 'Port': '1.12-49ecc658f87df43ba1bb65543a5987e0', 'Portgroup': '1.5-df4dc15967f67114d51176a98a901a83', 'Conductor': '1.4-a9703208fdab5fab8f1cec420be1b4a7', 'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370', @@ -699,7 +699,7 @@ expected_object_fingerprints = { 'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'NodeCRUDPayload': '1.15-9168946f843edd5859464aaa40ad70e0', 'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', - 'PortCRUDPayload': '1.4-9411a1701077ae9dc0aea27d6bf586fc', + 'PortCRUDPayload': '1.5-174b559bcc8e472cbe4c0afac2b40e66', 'NodeMaintenanceNotification': '1.0-59acc533c11d306f149846f922739c15', 'NodeConsoleNotification': '1.0-59acc533c11d306f149846f922739c15', 'PortgroupCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', diff --git a/ironic/tests/unit/objects/test_port.py b/ironic/tests/unit/objects/test_port.py index 2f43785764..0d347537dd 100644 --- a/ironic/tests/unit/objects/test_port.py +++ b/ironic/tests/unit/objects/test_port.py @@ -35,7 +35,8 @@ class TestPortObject(db_base.DbTestCase, obj_utils.SchemasTestMixIn): def setUp(self): super(TestPortObject, self).setUp() - self.fake_port = db_utils.get_test_port(name='port-name') + self.fake_port = db_utils.get_test_port( + name='port-name', description='port-description') def test_get_by_id(self): port_id = self.fake_port['id'] @@ -166,7 +167,7 @@ class TestPortObject(db_base.DbTestCase, obj_utils.SchemasTestMixIn): self.assertEqual(self.context, ports[0]._context) mock_get_list.assert_called_once_with( limit=None, marker=None, project=None, sort_dir=None, - sort_key=None) + sort_key=None, filters=None) def test_list_deprecated_owner(self): with mock.patch.object(self.dbapi, 'get_port_list', @@ -179,7 +180,7 @@ class TestPortObject(db_base.DbTestCase, obj_utils.SchemasTestMixIn): self.assertEqual(self.context, ports[0]._context) mock_get_list.assert_called_once_with( limit=None, marker=None, project='12345', sort_dir=None, - sort_key=None) + sort_key=None, filters=None) @mock.patch.object(obj_base.IronicObject, 'supports_version', spec_set=types.FunctionType) diff --git a/releasenotes/notes/port-description-4b68b22cac2e35a5.yaml b/releasenotes/notes/port-description-4b68b22cac2e35a5.yaml new file mode 100644 index 0000000000..5ec09349ad --- /dev/null +++ b/releasenotes/notes/port-description-4b68b22cac2e35a5.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + A new "description" field has been added to the Port object. This field + allows operators to provide human-readable descriptions to easily identify + physical ports on bare metal hosts.