From 63fefc3cebeb20251fc08fc80ab8bb0bfb89ec00 Mon Sep 17 00:00:00 2001 From: Jay Faulkner Date: Wed, 4 Mar 2026 12:58:25 -0800 Subject: [PATCH] Add dynamic portgroup fields (API 1.111) Add two new boolean fields in API microversion 1.111: - Port.available_for_dynamic_portgroup: user-editable via POST/PATCH, default True - Portgroup.dynamic_portgroup: read-only to API consumers, default False, settable internally by Ironic These fields support dynamic portgroup assignment where ports can be marked as available for automatic portgroup membership and portgroups can be flagged as dynamically managed. Assisted-By: claude-code Change-Id: Ib10f8bd5ba9e25b60cffd7d67d1e2c45be8f8533 Signed-off-by: Jay Faulkner --- .../contributor/webapi-version-history.rst | 8 +++ ironic/api/controllers/v1/port.py | 12 ++++ ironic/api/controllers/v1/portgroup.py | 6 +- ironic/api/controllers/v1/utils.py | 23 ++++++++ ironic/api/controllers/v1/versions.py | 5 +- ironic/common/release_mappings.py | 6 +- ...3b4c5d6e7f_add_dynamic_portgroup_fields.py | 38 +++++++++++++ ironic/db/sqlalchemy/models.py | 5 +- ironic/objects/port.py | 19 ++++++- ironic/objects/portgroup.py | 22 +++++-- .../unit/api/controllers/v1/test_port.py | 1 + .../unit/api/controllers/v1/test_portgroup.py | 25 ++++++++ ironic/tests/unit/db/utils.py | 7 ++- ironic/tests/unit/objects/test_objects.py | 8 +-- ironic/tests/unit/objects/test_port.py | 57 +++++++++++++++++++ ironic/tests/unit/objects/test_portgroup.py | 51 +++++++++++++++++ ...ort-portgroup-fields-ae7fbd3d5095fc06.yaml | 21 +++++++ 17 files changed, 296 insertions(+), 18 deletions(-) create mode 100644 ironic/db/sqlalchemy/alembic/versions/2a3b4c5d6e7f_add_dynamic_portgroup_fields.py create mode 100644 releasenotes/notes/tbn-port-portgroup-fields-ae7fbd3d5095fc06.yaml diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index 65f97ef6c7..398215e52f 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,14 @@ REST API Version History ======================== +1.111 (Gazpacho) +---------------------- + +Add ``available_for_dynamic_portgroup`` boolean field to the port object +(defaults to ``True``) and ``dynamic_portgroup`` boolean field to the +portgroup object. The port field is user-editable via POST and PATCH +requests. The portgroup field is read-only to API consumers. + 1.110 (Gazpacho) ---------------------- diff --git a/ironic/api/controllers/v1/port.py b/ironic/api/controllers/v1/port.py index b28acb4602..0a43d62e92 100644 --- a/ironic/api/controllers/v1/port.py +++ b/ironic/api/controllers/v1/port.py @@ -56,6 +56,7 @@ PORT_SCHEMA = { 'description': {'type': ['string', 'null'], 'maxLength': 255}, 'vendor': {'type': ['string', 'null'], 'maxLength': 32}, 'category': {'type': ['string', 'null'], 'maxLength': 80}, + 'available_for_dynamic_portgroup': {'type': 'boolean'}, }, 'required': ['address'], 'oneOf': [ @@ -82,6 +83,7 @@ PATCH_ALLOWED_FIELDS = [ 'description', 'vendor', 'category', + 'available_for_dynamic_portgroup', ] PORT_VALIDATOR_EXTRA = args.dict_valid( @@ -92,6 +94,7 @@ PORT_VALIDATOR_EXTRA = args.dict_valid( portgroup_uuid=args.uuid, pxe_enabled=args.boolean, uuid=args.uuid, + available_for_dynamic_portgroup=args.boolean, ) PORT_VALIDATOR = args.and_valid( @@ -149,6 +152,10 @@ def hide_fields_in_newer_versions(port): # if requested version is < 1.101, hide category field. if not api_utils.allow_port_category(): port.pop('category', None) + # if requested version is < 1.111, hide + # available_for_dynamic_portgroup field. + if not api_utils.allow_port_available_for_dynamic_portgroup(): + port.pop('available_for_dynamic_portgroup', None) def convert_with_links(rpc_port, fields=None, sanitize=True): @@ -168,6 +175,7 @@ def convert_with_links(rpc_port, fields=None, sanitize=True): 'description', 'vendor', 'category', + 'available_for_dynamic_portgroup', ) ) if rpc_port.portgroup_id: @@ -423,6 +431,10 @@ class PortsController(rest.RestController): if ('category' in fields and not api_utils.allow_port_category()): raise exception.NotAcceptable() + allow_dynpg = api_utils.allow_port_available_for_dynamic_portgroup + if ('available_for_dynamic_portgroup' in fields + and not allow_dynpg()): + raise exception.NotAcceptable() @METRICS.timer('PortsController.get_all') @method.expose() diff --git a/ironic/api/controllers/v1/portgroup.py b/ironic/api/controllers/v1/portgroup.py index 75d62bfd24..52226fdcba 100644 --- a/ironic/api/controllers/v1/portgroup.py +++ b/ironic/api/controllers/v1/portgroup.py @@ -93,6 +93,9 @@ def hide_fields_in_newer_versions(portgroup): # if requested version is < 1.103, hide category if not api_utils.allow_portgroup_category(): portgroup.pop('category', None) + # if requested version is < 1.111, hide dynamic_portgroup + if not api_utils.allow_portgroup_dynamic_portgroup(): + portgroup.pop('dynamic_portgroup', None) def portgroup_sanitize(portgroup, fields=None): @@ -123,7 +126,8 @@ def convert_with_links(rpc_portgroup, fields=None, sanitize=True): 'standalone_ports_supported', 'node_uuid', 'physical_network', - 'category' + 'category', + 'dynamic_portgroup', ) ) url = api.request.public_url diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 2e5942c549..2ead120f91 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -946,6 +946,9 @@ def check_allowed_portgroup_fields(fields): if (('mode' in fields or 'properties' in fields) and not allow_portgroup_mode_properties()): raise exception.NotAcceptable() + if ('dynamic_portgroup' in fields + and not allow_portgroup_dynamic_portgroup()): + raise exception.NotAcceptable() def check_allow_management_verbs(verb): @@ -2345,3 +2348,23 @@ def check_allow_filter_by_instance_name(instance_name): if instance_name is not None and not allow_node_instance_name(): raise exception.NotAcceptable( _("instance_name is not acceptable in this API version")) + + +def allow_port_available_for_dynamic_portgroup(): + """Check if available_for_dynamic_portgroup is allowed for ports. + + Version 1.111 of the API added available_for_dynamic_portgroup + field to the port object. + """ + return (api.request.version.minor + >= versions.MINOR_111_DYNAMIC_PORTGROUP) + + +def allow_portgroup_dynamic_portgroup(): + """Check if dynamic_portgroup is allowed for portgroups. + + Version 1.111 of the API added dynamic_portgroup field to the + portgroup object. + """ + return (api.request.version.minor + >= versions.MINOR_111_DYNAMIC_PORTGROUP) diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index de4d52de76..24a7d9d319 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -148,6 +148,8 @@ BASE_VERSION = 1 # v1.108: Add disable_ramdisk support for servicing # v1.109: Add health field to node object. # v1.110: Add support for aborting deployment in DEPLOYWAIT state +# v1.111: Add available_for_dynamic_portgroup to port, +# dynamic_portgroup to portgroup MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -260,6 +262,7 @@ MINOR_107_X_OPENSTACK_REQUEST_ID = 107 MINOR_108_SERVICE_DISABLE_RAMDISK = 108 MINOR_109_NODE_HEALTH = 109 MINOR_110_DEPLOYWAIT_ABORT = 110 +MINOR_111_DYNAMIC_PORTGROUP = 111 # When adding another version, update: # - MINOR_MAX_VERSION @@ -269,7 +272,7 @@ MINOR_110_DEPLOYWAIT_ABORT = 110 # - Add a comment describing the change above the list of consts -MINOR_MAX_VERSION = MINOR_110_DEPLOYWAIT_ABORT +MINOR_MAX_VERSION = MINOR_111_DYNAMIC_PORTGROUP # 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 e11becd0d3..1e48dd46ed 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -944,7 +944,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.110', + 'api': '1.111', 'rpc': '1.62', 'networking_rpc': '1.0', 'objects': { @@ -957,8 +957,8 @@ RELEASE_MAPPING = { 'Chassis': ['1.4', '1.3'], 'Deployment': ['1.1', '1.0'], 'DeployTemplate': ['1.2', '1.1'], - 'Port': ['1.15', '1.14', '1.13', '1.12'], - 'Portgroup': ['1.8', '1.7', '1.6', '1.5'], + 'Port': ['1.16', '1.15', '1.14', '1.13', '1.12'], + 'Portgroup': ['1.9', '1.8', '1.7', '1.6', '1.5'], 'Trait': ['1.1', '1.0'], 'TraitList': ['1.2', '1.1', '1.0'], 'VolumeConnector': ['1.1', '1.0'], diff --git a/ironic/db/sqlalchemy/alembic/versions/2a3b4c5d6e7f_add_dynamic_portgroup_fields.py b/ironic/db/sqlalchemy/alembic/versions/2a3b4c5d6e7f_add_dynamic_portgroup_fields.py new file mode 100644 index 0000000000..57ff6214b4 --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/2a3b4c5d6e7f_add_dynamic_portgroup_fields.py @@ -0,0 +1,38 @@ +# 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 dynamic portgroup fields + +Revision ID: 2a3b4c5d6e7f +Revises: c1fd28861bb9 +Create Date: 2026-03-04 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '2a3b4c5d6e7f' +down_revision = 'c1fd28861bb9' + + +def upgrade(): + op.add_column('ports', + sa.Column('available_for_dynamic_portgroup', + sa.Boolean(), + server_default=sa.true(), + nullable=False)) + op.add_column('portgroups', + sa.Column('dynamic_portgroup', + sa.Boolean(), + nullable=True)) diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index c4f1ca8b0f..bdb123fcd9 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -25,7 +25,7 @@ from oslo_db import options as db_options from oslo_db.sqlalchemy import models from oslo_db.sqlalchemy import types as db_types from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy import Boolean, Column, DateTime, false, Index +from sqlalchemy import Boolean, Column, DateTime, false, Index, true from sqlalchemy import BigInteger, ForeignKey, Integer from sqlalchemy import schema, String, Text from sqlalchemy import orm @@ -273,6 +273,8 @@ class Port(Base): description = Column(String(255), nullable=True) vendor = Column(String(32), nullable=True) category = Column(String(80), nullable=True) + available_for_dynamic_portgroup = Column(Boolean, server_default=true(), + nullable=False) _node_uuid = orm.relationship( "Node", @@ -305,6 +307,7 @@ class Portgroup(Base): properties = Column(db_types.JsonEncodedDict) physical_network = Column(String(64), nullable=True) category = Column(String(80), nullable=True) + dynamic_portgroup = Column(Boolean, nullable=True, default=False) _node_uuid = orm.relationship( "Node", diff --git a/ironic/objects/port.py b/ironic/objects/port.py index 26e183f636..2e1a236e9e 100644 --- a/ironic/objects/port.py +++ b/ironic/objects/port.py @@ -49,7 +49,8 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): # Version 1.13: Add vendor field # Version 1.14: Mark multiple methods as remotable methods. # Version 1.15: Add category field - VERSION = '1.15' + # Version 1.16: Add available_for_dynamic_portgroup field + VERSION = '1.16' dbapi = dbapi.get_instance() @@ -72,6 +73,8 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): 'description': object_fields.StringField(nullable=True), 'vendor': object_fields.StringField(nullable=True), 'category': object_fields.StringField(nullable=True), + 'available_for_dynamic_portgroup': object_fields.BooleanField( + default=True), } def _convert_field_by_version(self, field_name, introduced_version, @@ -134,6 +137,9 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): Version 1.15: remove category for unsupported versions if remove_unavailable_fields is True. + Version 1.16: remove available_for_dynamic_portgroup for + unsupported versions if remove_unavailable_fields is True. + :param target_version: the desired version of the object :param remove_unavailable_fields: True to remove fields that are unavailable in the target version; set this to True when @@ -171,6 +177,10 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): # Convert the category field. self._convert_field_by_version('category', (1, 15), target_version, remove_unavailable_fields) + # Convert the available_for_dynamic_portgroup field. + self._convert_field_by_version( + 'available_for_dynamic_portgroup', (1, 16), target_version, + remove_unavailable_fields, True) @object_base.remotable_classmethod def get(cls, context, port_id): @@ -504,7 +514,8 @@ class PortCRUDPayload(notification.NotificationPayloadBase): # Version 1.5: Add "description" field # Version 1.6: Add "vendor" field # Version 1.7: Add "category" field - VERSION = '1.7' + # Version 1.8: Add "available_for_dynamic_portgroup" field + VERSION = '1.8' SCHEMA = { 'address': ('port', 'address'), @@ -520,6 +531,8 @@ class PortCRUDPayload(notification.NotificationPayloadBase): 'description': ('port', 'description'), 'vendor': ('port', 'vendor'), 'category': ('port', 'category'), + 'available_for_dynamic_portgroup': ( + 'port', 'available_for_dynamic_portgroup'), } fields = { @@ -540,6 +553,8 @@ class PortCRUDPayload(notification.NotificationPayloadBase): 'description': object_fields.StringField(nullable=True), 'vendor': object_fields.StringField(nullable=True), 'category': object_fields.StringField(nullable=True), + 'available_for_dynamic_portgroup': object_fields.BooleanField( + default=True), } def __init__(self, port, node_uuid, portgroup_uuid): diff --git a/ironic/objects/portgroup.py b/ironic/objects/portgroup.py index 9036443c33..e3d0452670 100644 --- a/ironic/objects/portgroup.py +++ b/ironic/objects/portgroup.py @@ -41,7 +41,8 @@ class Portgroup(base.IronicObject, object_base.VersionedObjectDictCompat): # Version 1.6: Relevant methods changed to be remotable methods. # Version 1.7: Add physical_network field # Version 1.8: Add category field - VERSION = '1.8' + # Version 1.9: Add dynamic_portgroup field + VERSION = '1.9' dbapi = dbapi.get_instance() @@ -59,6 +60,8 @@ class Portgroup(base.IronicObject, object_base.VersionedObjectDictCompat): 'properties': object_fields.FlexibleDictField(nullable=True), 'physical_network': object_fields.StringField(nullable=True), 'category': object_fields.StringField(nullable=True), + 'dynamic_portgroup': object_fields.BooleanField( + nullable=True, default=False), } # TODO(clif): Abstract this, already exists in Port object. @@ -112,6 +115,9 @@ class Portgroup(base.IronicObject, object_base.VersionedObjectDictCompat): Version 1.8: remove category for unsupported versions if remove_unavailable_fields is True. + Version 1.9: remove dynamic_portgroup for unsupported versions + if remove_unavailable_fields is True. + :param target_version: the desired version of the object :param remove_unavailable_fields: True to remove fields that are unavailable in the target version; set this to True when @@ -142,6 +148,10 @@ class Portgroup(base.IronicObject, object_base.VersionedObjectDictCompat): # Convert the category field. self._convert_field_by_version('category', (1, 8), target_version, remove_unavailable_fields) + # Convert the dynamic_portgroup field. + self._convert_field_by_version( + 'dynamic_portgroup', (1, 9), target_version, + remove_unavailable_fields, False) @object_base.remotable_classmethod def get(cls, context, portgroup_ident): @@ -418,7 +428,8 @@ class PortgroupCRUDPayload(notification.NotificationPayloadBase): # Version 1.0: Initial version # Version 1.1: Add physical_network field # Version 1.2: Add category field - VERSION = '1.2' + # Version 1.3: Add dynamic_portgroup field + VERSION = '1.3' SCHEMA = { 'address': ('portgroup', 'address'), @@ -432,7 +443,8 @@ class PortgroupCRUDPayload(notification.NotificationPayloadBase): 'standalone_ports_supported'), 'created_at': ('portgroup', 'created_at'), 'updated_at': ('portgroup', 'updated_at'), - 'uuid': ('portgroup', 'uuid') + 'uuid': ('portgroup', 'uuid'), + 'dynamic_portgroup': ('portgroup', 'dynamic_portgroup'), } fields = { @@ -448,7 +460,9 @@ class PortgroupCRUDPayload(notification.NotificationPayloadBase): nullable=True), 'created_at': object_fields.DateTimeField(nullable=True), 'updated_at': object_fields.DateTimeField(nullable=True), - 'uuid': object_fields.UUIDField() + 'uuid': object_fields.UUIDField(), + 'dynamic_portgroup': object_fields.BooleanField( + nullable=True, default=False), } def __init__(self, portgroup, node_uuid): diff --git a/ironic/tests/unit/api/controllers/v1/test_port.py b/ironic/tests/unit/api/controllers/v1/test_port.py index 1bbb9c53d3..7547ff02f3 100644 --- a/ironic/tests/unit/api/controllers/v1/test_port.py +++ b/ironic/tests/unit/api/controllers/v1/test_port.py @@ -2025,6 +2025,7 @@ class TestPost(test_api_base.BaseApiTest): pdict.pop('description') pdict.pop('vendor') pdict.pop('category') + pdict.pop('available_for_dynamic_portgroup') 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/api/controllers/v1/test_portgroup.py b/ironic/tests/unit/api/controllers/v1/test_portgroup.py index ecb3aff417..7eb82cf0e2 100644 --- a/ironic/tests/unit/api/controllers/v1/test_portgroup.py +++ b/ironic/tests/unit/api/controllers/v1/test_portgroup.py @@ -1141,6 +1141,21 @@ class TestPatch(test_api_base.BaseApiTest): self.assertTrue(response.json['error_message']) self.assertFalse(mock_upd.called) + def test_update_portgroup_dynamic_portgroup_not_allowed( + self, mock_upd): + mock_upd.return_value = self.portgroup + response = self.patch_json( + '/portgroups/%s' % self.portgroup.uuid, + [{'path': '/dynamic_portgroup', + 'value': True, + 'op': 'replace'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertTrue(response.json['error_message']) + self.assertFalse(mock_upd.called) + def test_update_portgroup_mode_properties(self, mock_upd): mock_upd.return_value = self.portgroup mock_upd.return_value.mode = '802.3ad' @@ -1461,6 +1476,16 @@ class TestPost(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertTrue(response.json['error_message']) + def test_create_portgroup_dynamic_portgroup_not_allowed(self): + pdict = apiutils.post_get_test_portgroup() + pdict['dynamic_portgroup'] = True + response = self.post_json('/portgroups', pdict, + expect_errors=True, + headers=self.headers) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + def test_create_portgroup_mode_old_api_version(self): for kwarg in [{'mode': '802.3ad'}, {'properties': {'bond_prop': 123}}]: pdict = apiutils.post_get_test_portgroup(**kwarg) diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index 0eaed19b45..ad39a8fc6b 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -292,7 +292,9 @@ def get_test_port(**kw): 'name': kw.get('name'), 'description': kw.get('description'), 'vendor': kw.get('vendor'), - 'category': kw.get('category') + 'category': kw.get('category'), + 'available_for_dynamic_portgroup': kw.get( + 'available_for_dynamic_portgroup', True), } @@ -470,7 +472,8 @@ def get_test_portgroup(**kw): 'mode': kw.get('mode'), 'properties': kw.get('properties', {}), 'physical_network': kw.get('physical_network'), - 'category': kw.get('category') + 'category': kw.get('category'), + 'dynamic_portgroup': kw.get('dynamic_portgroup', False), } diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 22f85e1884..66c84a059f 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -678,8 +678,8 @@ expected_object_fingerprints = { 'Node': '1.44-c9cd729566ab4c6fd69aaa4eaa1386bf', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'Chassis': '1.4-fe427272d8bad232a8d46e996a5ca42a', - 'Port': '1.15-013610c0fe2e370b14f4304e0d8aeb3a', - 'Portgroup': '1.8-c931f147ebe450b7c91cc51acfc59472', + 'Port': '1.16-f66d781ac2c9e2906531cc523be56141', + 'Portgroup': '1.9-0c02f589c88ef5a8f60248454783fbbc', 'Conductor': '1.6-ed00540fae97aa1c9982f9017c6e8b68', 'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370', 'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d', @@ -699,11 +699,11 @@ expected_object_fingerprints = { 'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'NodeCRUDPayload': '1.15-9168946f843edd5859464aaa40ad70e0', 'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', - 'PortCRUDPayload': '1.7-aaefef8ba3a94030753c1e3b9a29741b', + 'PortCRUDPayload': '1.8-e6234f3aafda0dae874feb6f9ec8715f', 'NodeMaintenanceNotification': '1.0-59acc533c11d306f149846f922739c15', 'NodeConsoleNotification': '1.0-59acc533c11d306f149846f922739c15', 'PortgroupCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', - 'PortgroupCRUDPayload': '1.2-ea41e2dfcd5817ec4d3f9768e3f26c6e', + 'PortgroupCRUDPayload': '1.3-0d2b8747f09f71ed84a804aa6888657b', 'VolumeConnectorCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'VolumeConnectorCRUDPayload': '1.0-5e8dbb41e05b6149d8f7bfd4daff9339', 'VolumeTargetCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', diff --git a/ironic/tests/unit/objects/test_port.py b/ironic/tests/unit/objects/test_port.py index eddea00b59..06a5aac2b9 100644 --- a/ironic/tests/unit/objects/test_port.py +++ b/ironic/tests/unit/objects/test_port.py @@ -424,3 +424,60 @@ class TestConvertToVersion(db_base.DbTestCase): port._convert_to_version("1.9", False) self.assertIsNone(port.name) self.assertNotIn('name', port.obj_get_changes()) + + def test_available_for_dynamic_portgroup_supported_missing(self): + port = objects.Port(self.context, **self.fake_port) + delattr(port, 'available_for_dynamic_portgroup') + port.obj_reset_changes() + port._convert_to_version("1.16") + self.assertTrue(port.available_for_dynamic_portgroup) + self.assertIn('available_for_dynamic_portgroup', + port.obj_get_changes()) + + def test_available_for_dynamic_portgroup_supported_set(self): + port = objects.Port(self.context, **self.fake_port) + port.available_for_dynamic_portgroup = False + port.obj_reset_changes() + port._convert_to_version("1.16") + self.assertFalse(port.available_for_dynamic_portgroup) + self.assertNotIn('available_for_dynamic_portgroup', + port.obj_get_changes()) + + def test_available_for_dynamic_portgroup_unsupported(self): + port = objects.Port(self.context, **self.fake_port) + port._convert_to_version("1.15") + self.assertNotIn('available_for_dynamic_portgroup', port) + + def test_available_for_dynamic_portgroup_unsupported_missing(self): + port = objects.Port(self.context, **self.fake_port) + delattr(port, 'available_for_dynamic_portgroup') + port.obj_reset_changes() + port._convert_to_version("1.15") + self.assertNotIn('available_for_dynamic_portgroup', port) + + def test_available_for_dynamic_portgroup_unsupported_set_remove(self): + port = objects.Port(self.context, **self.fake_port) + port.available_for_dynamic_portgroup = True + port.obj_reset_changes() + port._convert_to_version("1.15") + self.assertNotIn('available_for_dynamic_portgroup', port) + + def test_available_for_dynamic_portgroup_unsupported_no_remove_non_default( + self): + port = objects.Port(self.context, **self.fake_port) + port.available_for_dynamic_portgroup = False + port.obj_reset_changes() + port._convert_to_version("1.15", False) + self.assertTrue(port.available_for_dynamic_portgroup) + self.assertIn('available_for_dynamic_portgroup', + port.obj_get_changes()) + + def test_available_for_dynamic_portgroup_unsupported_no_remove_default( + self): + port = objects.Port(self.context, **self.fake_port) + port.available_for_dynamic_portgroup = True + port.obj_reset_changes() + port._convert_to_version("1.15", False) + self.assertTrue(port.available_for_dynamic_portgroup) + self.assertNotIn('available_for_dynamic_portgroup', + port.obj_get_changes()) diff --git a/ironic/tests/unit/objects/test_portgroup.py b/ironic/tests/unit/objects/test_portgroup.py index 42e17e23f2..4d6810db30 100644 --- a/ironic/tests/unit/objects/test_portgroup.py +++ b/ironic/tests/unit/objects/test_portgroup.py @@ -245,3 +245,54 @@ class TestConvertToVersion(db_base.DbTestCase): portgroup._convert_to_version('1.4', False) # no change self.assertEqual(vif2, portgroup.internal_info['tenant_vif_port_id']) + + def test_dynamic_portgroup_supported_missing(self): + portgroup = objects.Portgroup(self.context, **self.fake_portgroup) + delattr(portgroup, 'dynamic_portgroup') + portgroup.obj_reset_changes() + portgroup._convert_to_version("1.9") + self.assertFalse(portgroup.dynamic_portgroup) + self.assertIn('dynamic_portgroup', portgroup.obj_get_changes()) + + def test_dynamic_portgroup_supported_set(self): + portgroup = objects.Portgroup(self.context, **self.fake_portgroup) + portgroup.dynamic_portgroup = True + portgroup.obj_reset_changes() + portgroup._convert_to_version("1.9") + self.assertTrue(portgroup.dynamic_portgroup) + self.assertNotIn('dynamic_portgroup', portgroup.obj_get_changes()) + + def test_dynamic_portgroup_unsupported(self): + portgroup = objects.Portgroup(self.context, **self.fake_portgroup) + portgroup._convert_to_version("1.8") + self.assertNotIn('dynamic_portgroup', portgroup) + + def test_dynamic_portgroup_unsupported_missing(self): + portgroup = objects.Portgroup(self.context, **self.fake_portgroup) + delattr(portgroup, 'dynamic_portgroup') + portgroup.obj_reset_changes() + portgroup._convert_to_version("1.8") + self.assertNotIn('dynamic_portgroup', portgroup) + + def test_dynamic_portgroup_unsupported_set_remove(self): + portgroup = objects.Portgroup(self.context, **self.fake_portgroup) + portgroup.dynamic_portgroup = False + portgroup.obj_reset_changes() + portgroup._convert_to_version("1.8") + self.assertNotIn('dynamic_portgroup', portgroup) + + def test_dynamic_portgroup_unsupported_no_remove_non_default(self): + portgroup = objects.Portgroup(self.context, **self.fake_portgroup) + portgroup.dynamic_portgroup = True + portgroup.obj_reset_changes() + portgroup._convert_to_version("1.8", False) + self.assertFalse(portgroup.dynamic_portgroup) + self.assertIn('dynamic_portgroup', portgroup.obj_get_changes()) + + def test_dynamic_portgroup_unsupported_no_remove_default(self): + portgroup = objects.Portgroup(self.context, **self.fake_portgroup) + portgroup.dynamic_portgroup = False + portgroup.obj_reset_changes() + portgroup._convert_to_version("1.8", False) + self.assertFalse(portgroup.dynamic_portgroup) + self.assertNotIn('dynamic_portgroup', portgroup.obj_get_changes()) diff --git a/releasenotes/notes/tbn-port-portgroup-fields-ae7fbd3d5095fc06.yaml b/releasenotes/notes/tbn-port-portgroup-fields-ae7fbd3d5095fc06.yaml new file mode 100644 index 0000000000..bd0d7a2775 --- /dev/null +++ b/releasenotes/notes/tbn-port-portgroup-fields-ae7fbd3d5095fc06.yaml @@ -0,0 +1,21 @@ +--- +features: + - | + Adds two new fields for trait-based networking in REST API version + *1.111*: + + * ``Port.available_for_dynamic_portgroup``: a boolean field (default + True) indicating whether the port should be considered for dynamic + portgroup assignment. This field is operator-editable via POST and + PATCH. + * ``Portgroup.dynamic_portgroup``: a read-only boolean field (default + False) that indicates whether the portgroup was dynamically created + by the trait-based networking subsystem. This field is managed + internally by Ironic and cannot be modified by API consumers. +upgrade: + - | + Adds ``available_for_dynamic_portgroup`` and ``dynamic_portgroup`` + fields to the port and portgroup objects respectively in REST API + version *1.111*. Upgrading to this release will set + ``available_for_dynamic_portgroup`` to True for all existing ports + and ``dynamic_portgroup`` to False for all existing portgroups.