Add portgroup configuration fields

This change adds mode and properties fields into portgroup object
and adds a new API microversion to work with them.

It also makes portgroups' address field optional for all API
microversions.

Partial-Bug: #1618754
Change-Id: Id8c62fa56908040b0df16cc54c122ce2473a4587
This commit is contained in:
Vladyslav Drok 2016-11-16 17:26:09 +02:00
parent 3f5bccd008
commit cce428112e
19 changed files with 338 additions and 17 deletions

View File

@ -2,6 +2,10 @@
REST API Version History REST API Version History
======================== ========================
**1.26** (Ocata)
Add portgroup ``mode`` and ``properties`` fields.
**1.25** (Ocata) **1.25** (Ocata)
Add possibility to unset chassis_uuid from a node. Add possibility to unset chassis_uuid from a node.

View File

@ -123,6 +123,12 @@
# value) # value)
#state_path = $pybasedir #state_path = $pybasedir
# Default mode for portgroups. Allowed values can be found in
# the linux kernel documentation on bonding:
# https://www.kernel.org/doc/Documentation/networking/bonding.txt.
# (string value)
#default_portgroup_mode = active-backup
# Name of this node. This can be an opaque identifier. It is # Name of this node. This can be an opaque identifier. It is
# not necessarily a hostname, FQDN, or IP address. However, # not necessarily a hostname, FQDN, or IP address. However,
# the node name must be valid within an AMQP key, and if using # the node name must be valid within an AMQP key, and if using
@ -2300,6 +2306,11 @@
# Minimum value: 5 # Minimum value: 5
#default_notify_timeout = 30 #default_notify_timeout = 30
# The duration to schedule a purge of idle sender links.
# Detach link after expiry. (integer value)
# Minimum value: 1
#default_sender_link_timeout = 600
# Indicates the addressing mode used by the driver. # Indicates the addressing mode used by the driver.
# Permitted values: # Permitted values:
# 'legacy' - use legacy non-routable addressing # 'legacy' - use legacy non-routable addressing

View File

@ -71,7 +71,7 @@ class Portgroup(base.APIBase):
uuid = types.uuid uuid = types.uuid
"""Unique UUID for this portgroup""" """Unique UUID for this portgroup"""
address = wsme.wsattr(types.macaddress, mandatory=True) address = wsme.wsattr(types.macaddress)
"""MAC Address for this portgroup""" """MAC Address for this portgroup"""
extra = {wtypes.text: types.jsontype} extra = {wtypes.text: types.jsontype}
@ -94,6 +94,14 @@ class Portgroup(base.APIBase):
"""Indicates whether ports of this portgroup may be used as """Indicates whether ports of this portgroup may be used as
single NIC ports""" single NIC ports"""
mode = wsme.wsattr(wtypes.text)
"""The mode for this portgroup. See linux bonding
documentation for details:
https://www.kernel.org/doc/Documentation/networking/bonding.txt"""
properties = {wtypes.text: types.jsontype}
"""This portgroup's properties"""
ports = wsme.wsattr([link.Link], readonly=True) ports = wsme.wsattr([link.Link], readonly=True)
"""Links to the collection of ports of this portgroup""" """Links to the collection of ports of this portgroup"""
@ -165,6 +173,8 @@ class Portgroup(base.APIBase):
extra={'foo': 'bar'}, extra={'foo': 'bar'},
internal_info={'baz': 'boo'}, internal_info={'baz': 'boo'},
standalone_ports_supported=True, standalone_ports_supported=True,
mode='active-backup',
properties={},
created_at=datetime.datetime(2000, 1, 1, 12, 0, 0), created_at=datetime.datetime(2000, 1, 1, 12, 0, 0),
updated_at=datetime.datetime(2000, 1, 1, 12, 0, 0)) updated_at=datetime.datetime(2000, 1, 1, 12, 0, 0))
# NOTE(lucasagomes): node_uuid getter() method look at the # NOTE(lucasagomes): node_uuid getter() method look at the
@ -218,7 +228,7 @@ class PortgroupsController(pecan.rest.RestController):
'detail': ['GET'], 'detail': ['GET'],
} }
invalid_sort_key_list = ['extra', 'internal_info'] invalid_sort_key_list = ['extra', 'internal_info', 'properties']
_subcontroller_map = { _subcontroller_map = {
'ports': port.PortsController, 'ports': port.PortsController,
@ -339,6 +349,8 @@ class PortgroupsController(pecan.rest.RestController):
cdict = pecan.request.context.to_dict() cdict = pecan.request.context.to_dict()
policy.authorize('baremetal:portgroup:get', cdict, cdict) policy.authorize('baremetal:portgroup:get', cdict, cdict)
api_utils.check_allowed_portgroup_fields(fields)
if fields is None: if fields is None:
fields = _DEFAULT_RETURN_FIELDS fields = _DEFAULT_RETURN_FIELDS
@ -400,6 +412,8 @@ class PortgroupsController(pecan.rest.RestController):
if self.parent_node_ident: if self.parent_node_ident:
raise exception.OperationNotPermitted() raise exception.OperationNotPermitted()
api_utils.check_allowed_portgroup_fields(fields)
rpc_portgroup = api_utils.get_rpc_portgroup(portgroup_ident) rpc_portgroup = api_utils.get_rpc_portgroup(portgroup_ident)
return Portgroup.convert_with_links(rpc_portgroup, fields=fields) return Portgroup.convert_with_links(rpc_portgroup, fields=fields)
@ -419,6 +433,11 @@ class PortgroupsController(pecan.rest.RestController):
if self.parent_node_ident: if self.parent_node_ident:
raise exception.OperationNotPermitted() raise exception.OperationNotPermitted()
if (not api_utils.allow_portgroup_mode_properties() and
(portgroup.mode is not wtypes.Unset or
portgroup.properties is not wtypes.Unset)):
raise exception.NotAcceptable()
if (portgroup.name and if (portgroup.name and
not api_utils.is_valid_logical_name(portgroup.name)): not api_utils.is_valid_logical_name(portgroup.name)):
error_msg = _("Cannot create portgroup with invalid name " error_msg = _("Cannot create portgroup with invalid name "
@ -452,6 +471,11 @@ class PortgroupsController(pecan.rest.RestController):
if self.parent_node_ident: if self.parent_node_ident:
raise exception.OperationNotPermitted() raise exception.OperationNotPermitted()
if (not api_utils.allow_portgroup_mode_properties() and
(api_utils.is_path_updated(patch, '/mode') or
api_utils.is_path_updated(patch, '/properties'))):
raise exception.NotAcceptable()
rpc_portgroup = api_utils.get_rpc_portgroup(portgroup_ident) rpc_portgroup = api_utils.get_rpc_portgroup(portgroup_ident)
names = api_utils.get_patch_values(patch, '/name') names = api_utils.get_patch_values(patch, '/name')

View File

@ -113,6 +113,18 @@ def is_path_removed(patch, path):
return True return True
def is_path_updated(patch, path):
"""Returns whether the patch includes operation on path (or its subpath).
:param patch: HTTP PATCH request body.
:param path: the path to check.
:returns: True if path or subpath being patched, False otherwise.
"""
path = path.rstrip('/')
for p in patch:
return p['path'] == path or p['path'].startswith(path + '/')
def allow_node_logical_names(): def allow_node_logical_names():
# v1.5 added logical name aliases # v1.5 added logical name aliases
return pecan.request.version.minor >= versions.MINOR_5_NODE_NAME return pecan.request.version.minor >= versions.MINOR_5_NODE_NAME
@ -276,6 +288,19 @@ def check_allowed_fields(fields):
raise exception.NotAcceptable() raise exception.NotAcceptable()
def check_allowed_portgroup_fields(fields):
"""Check if fetching a particular field of a portgroup is allowed.
This method checks if the required version is being requested for fields
that are only allowed to be fetched in a particular API version.
"""
if fields is None:
return
if (('mode' in fields or 'properties' in fields) and
not allow_portgroup_mode_properties()):
raise exception.NotAcceptable()
def check_allow_management_verbs(verb): def check_allow_management_verbs(verb):
min_version = MIN_VERB_VERSIONS.get(verb) min_version = MIN_VERB_VERSIONS.get(verb)
if min_version is not None and pecan.request.version.minor < min_version: if min_version is not None and pecan.request.version.minor < min_version:
@ -427,6 +452,16 @@ def allow_remove_chassis_uuid():
versions.MINOR_25_UNSET_CHASSIS_UUID) versions.MINOR_25_UNSET_CHASSIS_UUID)
def allow_portgroup_mode_properties():
"""Check if mode and properties can be added to/queried from a portgroup.
Version 1.26 of the API added mode and properties fields to portgroup
object.
"""
return (pecan.request.version.minor >=
versions.MINOR_26_PORTGROUP_MODE_PROPERTIES)
def get_controller_reserved_names(cls): def get_controller_reserved_names(cls):
"""Get reserved names for a given controller. """Get reserved names for a given controller.

View File

@ -56,6 +56,7 @@ BASE_VERSION = 1
# v1.24: Add subcontrollers: node.portgroup, portgroup.ports. # v1.24: Add subcontrollers: node.portgroup, portgroup.ports.
# Add port.portgroup_uuid field. # Add port.portgroup_uuid field.
# v1.25: Add possibility to unset chassis_uuid from node. # v1.25: Add possibility to unset chassis_uuid from node.
# v1.26: Add portgroup.mode and portgroup.properties.
MINOR_0_JUNO = 0 MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1 MINOR_1_INITIAL_VERSION = 1
@ -83,11 +84,12 @@ MINOR_22_LOOKUP_HEARTBEAT = 22
MINOR_23_PORTGROUPS = 23 MINOR_23_PORTGROUPS = 23
MINOR_24_PORTGROUPS_SUBCONTROLLERS = 24 MINOR_24_PORTGROUPS_SUBCONTROLLERS = 24
MINOR_25_UNSET_CHASSIS_UUID = 25 MINOR_25_UNSET_CHASSIS_UUID = 25
MINOR_26_PORTGROUP_MODE_PROPERTIES = 26
# When adding another version, update MINOR_MAX_VERSION and also update # When adding another version, update MINOR_MAX_VERSION and also update
# doc/source/dev/webapi-version-history.rst with a detailed explanation of # doc/source/dev/webapi-version-history.rst with a detailed explanation of
# what the version has changed. # what the version has changed.
MINOR_MAX_VERSION = MINOR_25_UNSET_CHASSIS_UUID MINOR_MAX_VERSION = MINOR_26_PORTGROUP_MODE_PROPERTIES
# String representations of the minor and maximum versions # String representations of the minor and maximum versions
MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -177,6 +177,16 @@ path_opts = [
help=_("Top-level directory for maintaining ironic's state.")), help=_("Top-level directory for maintaining ironic's state.")),
] ]
portgroup_opts = [
cfg.StrOpt(
'default_portgroup_mode', default='active-backup',
help=_(
'Default mode for portgroups. Allowed values can be found in the '
'linux kernel documentation on bonding: '
'https://www.kernel.org/doc/Documentation/networking/bonding.txt.')
),
]
service_opts = [ service_opts = [
cfg.StrOpt('host', cfg.StrOpt('host',
default=socket.getfqdn(), default=socket.getfqdn(),
@ -211,5 +221,6 @@ def register_opts(conf):
conf.register_opts(netconf_opts) conf.register_opts(netconf_opts)
conf.register_opts(notification_opts) conf.register_opts(notification_opts)
conf.register_opts(path_opts) conf.register_opts(path_opts)
conf.register_opts(portgroup_opts)
conf.register_opts(service_opts) conf.register_opts(service_opts)
conf.register_opts(utils_opts) conf.register_opts(utils_opts)

View File

@ -24,6 +24,7 @@ _default_opt_lists = [
ironic.conf.default.netconf_opts, ironic.conf.default.netconf_opts,
ironic.conf.default.notification_opts, ironic.conf.default.notification_opts,
ironic.conf.default.path_opts, ironic.conf.default.path_opts,
ironic.conf.default.portgroup_opts,
ironic.conf.default.service_opts, ironic.conf.default.service_opts,
ironic.conf.default.utils_opts, ironic.conf.default.utils_opts,
] ]

View File

@ -0,0 +1,40 @@
# 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 portgroup configuration fields
Revision ID: 493d8f27f235
Revises: 60cf717201bc
Create Date: 2016-11-15 18:09:31.362613
"""
# revision identifiers, used by Alembic.
revision = '493d8f27f235'
down_revision = '1a59178ebdf6'
from alembic import op
import sqlalchemy as sa
from sqlalchemy import sql
from ironic.conf import CONF
def upgrade():
op.add_column('portgroups', sa.Column('properties', sa.Text(),
nullable=True))
op.add_column('portgroups', sa.Column('mode', sa.String(255)))
portgroups = sql.table('portgroups',
sql.column('mode', sa.String(255)))
op.execute(
portgroups.update().values({'mode': CONF.default_portgroup_mode}))

View File

@ -568,6 +568,8 @@ class Connection(api.Connection):
def create_portgroup(self, values): def create_portgroup(self, values):
if not values.get('uuid'): if not values.get('uuid'):
values['uuid'] = uuidutils.generate_uuid() values['uuid'] = uuidutils.generate_uuid()
if not values.get('mode'):
values['mode'] = CONF.default_portgroup_mode
portgroup = models.Portgroup() portgroup = models.Portgroup()
portgroup.update(values) portgroup.update(values)

View File

@ -191,6 +191,8 @@ class Portgroup(Base):
extra = Column(db_types.JsonEncodedDict) extra = Column(db_types.JsonEncodedDict)
internal_info = Column(db_types.JsonEncodedDict) internal_info = Column(db_types.JsonEncodedDict)
standalone_ports_supported = Column(Boolean, default=True) standalone_ports_supported = Column(Boolean, default=True)
mode = Column(String(255))
properties = Column(db_types.JsonEncodedDict)
class NodeTag(Base): class NodeTag(Base):

View File

@ -30,7 +30,8 @@ class Portgroup(base.IronicObject, object_base.VersionedObjectDictCompat):
# Version 1.0: Initial version # Version 1.0: Initial version
# Version 1.1: Add internal_info field # Version 1.1: Add internal_info field
# Version 1.2: Add standalone_ports_supported field # Version 1.2: Add standalone_ports_supported field
VERSION = '1.2' # Version 1.3: Add mode and properties fields
VERSION = '1.3'
dbapi = dbapi.get_instance() dbapi = dbapi.get_instance()
@ -43,6 +44,8 @@ class Portgroup(base.IronicObject, object_base.VersionedObjectDictCompat):
'extra': object_fields.FlexibleDictField(nullable=True), 'extra': object_fields.FlexibleDictField(nullable=True),
'internal_info': object_fields.FlexibleDictField(nullable=True), 'internal_info': object_fields.FlexibleDictField(nullable=True),
'standalone_ports_supported': object_fields.BooleanField(), 'standalone_ports_supported': object_fields.BooleanField(),
'mode': object_fields.StringField(nullable=True),
'properties': object_fields.FlexibleDictField(nullable=True),
} }
# NOTE(xek): We don't want to enable RPC on this call just yet. Remotable # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable

View File

@ -139,7 +139,18 @@ def post_get_test_node(**kw):
def portgroup_post_data(**kw): def portgroup_post_data(**kw):
"""Return a Portgroup object without internal attributes.""" """Return a Portgroup object without internal attributes."""
portgroup = utils.get_test_portgroup(**kw) portgroup = utils.get_test_portgroup(**kw)
# node_id is not a part of the API object
portgroup.pop('node_id') portgroup.pop('node_id')
# NOTE(jroll): pop out fields that were introduced in later API versions,
# unless explicitly requested. Otherwise, these will cause tests using
# older API versions to fail.
new_api_ver_arguments = ['mode', 'properties']
for arg in new_api_ver_arguments:
if arg not in kw:
portgroup.pop(arg)
internal = portgroup_controller.PortgroupPatchType.internal_attrs() internal = portgroup_controller.PortgroupPatchType.internal_attrs()
return remove_internal(portgroup, internal) return remove_internal(portgroup, internal)

View File

@ -92,6 +92,17 @@ class TestListPortgroups(test_api_base.BaseApiTest):
# We always append "links" # We always append "links"
self.assertItemsEqual(['address', 'extra', 'links'], data) self.assertItemsEqual(['address', 'extra', 'links'], data)
def test_get_one_mode_field_lower_api_version(self):
portgroup = obj_utils.create_test_portgroup(self.context,
node_id=self.node.id)
headers = {api_base.Version.string: '1.25'}
fields = 'address,mode'
response = self.get_json(
'/portgroups/%s?fields=%s' % (portgroup.uuid, fields),
headers=headers, expect_errors=True)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
self.assertEqual('application/json', response.content_type)
def test_get_collection_custom_fields(self): def test_get_collection_custom_fields(self):
fields = 'uuid,extra' fields = 'uuid,extra'
for i in range(3): for i in range(3):
@ -111,6 +122,16 @@ class TestListPortgroups(test_api_base.BaseApiTest):
# We always append "links" # We always append "links"
self.assertItemsEqual(['uuid', 'extra', 'links'], portgroup) self.assertItemsEqual(['uuid', 'extra', 'links'], portgroup)
def test_get_collection_properties_field_lower_api_version(self):
obj_utils.create_test_portgroup(self.context, node_id=self.node.id)
headers = {api_base.Version.string: '1.25'}
fields = 'address,properties'
response = self.get_json(
'/portgroups/?fields=%s' % fields,
headers=headers, expect_errors=True)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
self.assertEqual('application/json', response.content_type)
def test_get_custom_fields_invalid_fields(self): def test_get_custom_fields_invalid_fields(self):
portgroup = obj_utils.create_test_portgroup(self.context, portgroup = obj_utils.create_test_portgroup(self.context,
node_id=self.node.id) node_id=self.node.id)
@ -358,7 +379,7 @@ class TestListPortgroups(test_api_base.BaseApiTest):
self.assertEqual(sorted(portgroups), uuids) self.assertEqual(sorted(portgroups), uuids)
def test_sort_key_invalid(self): def test_sort_key_invalid(self):
invalid_keys_list = ['foo', 'extra'] invalid_keys_list = ['foo', 'extra', 'internal_info', 'properties']
for invalid_key in invalid_keys_list: for invalid_key in invalid_keys_list:
response = self.get_json('/portgroups?sort_key=%s' % invalid_key, response = self.get_json('/portgroups?sort_key=%s' % invalid_key,
expect_errors=True, headers=self.headers) expect_errors=True, headers=self.headers)
@ -669,16 +690,17 @@ class TestPatch(test_api_base.BaseApiTest):
self.assertTrue(response.json['error_message']) self.assertTrue(response.json['error_message'])
self.assertFalse(mock_upd.called) self.assertFalse(mock_upd.called)
def test_remove_mandatory_field(self, mock_upd): def test_remove_address(self, mock_upd):
mock_upd.return_value = self.portgroup
mock_upd.return_value.address = None
response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, response = self.patch_json('/portgroups/%s' % self.portgroup.uuid,
[{'path': '/address', [{'path': '/address',
'op': 'remove'}], 'op': 'remove'}],
expect_errors=True,
headers=self.headers) headers=self.headers)
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.BAD_REQUEST, response.status_code) self.assertEqual(http_client.OK, response.status_code)
self.assertTrue(response.json['error_message']) self.assertIsNone(response.json['address'])
self.assertFalse(mock_upd.called) self.assertTrue(mock_upd.called)
def test_add_root(self, mock_upd): def test_add_root(self, mock_upd):
address = 'aa:bb:cc:dd:ee:ff' address = 'aa:bb:cc:dd:ee:ff'
@ -801,6 +823,39 @@ class TestPatch(test_api_base.BaseApiTest):
self.assertTrue(response.json['error_message']) self.assertTrue(response.json['error_message'])
self.assertFalse(mock_upd.called) 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'
mock_upd.return_value.properties = {'bond_param': '100'}
response = self.patch_json('/portgroups/%s' % self.portgroup.uuid,
[{'path': '/mode',
'value': '802.3ad',
'op': 'add'},
{'path': '/properties/bond_param',
'value': '100',
'op': 'add'}],
headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
self.assertEqual('802.3ad', response.json['mode'])
self.assertEqual({'bond_param': '100'}, response.json['properties'])
def _test_update_portgroup_mode_properties_bad_api_version(self, patch,
mock_upd):
response = self.patch_json('/portgroups/%s' % self.portgroup.uuid,
patch, expect_errors=True,
headers={api_base.Version.string: '1.25'})
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
self.assertTrue(response.json['error_message'])
self.assertFalse(mock_upd.called)
def test_update_portgroup_mode_properties_bad_api_version(self, mock_upd):
self._test_update_portgroup_mode_properties_bad_api_version(
[{'path': '/mode', 'op': 'remove'}], mock_upd)
self._test_update_portgroup_mode_properties_bad_api_version(
[{'path': '/properties/abc', 'op': 'add', 'value': 123}], mock_upd)
class TestPost(test_api_base.BaseApiTest): class TestPost(test_api_base.BaseApiTest):
headers = {api_base.Version.string: str(api_v1.MAX_VER)} headers = {api_base.Version.string: str(api_v1.MAX_VER)}
@ -890,14 +945,13 @@ class TestPost(test_api_base.BaseApiTest):
headers=self.headers) headers=self.headers)
self.assertEqual(pdict['extra'], result['extra']) self.assertEqual(pdict['extra'], result['extra'])
def test_create_portgroup_no_mandatory_field_address(self): def test_create_portgroup_no_address(self):
pdict = apiutils.post_get_test_portgroup() pdict = apiutils.post_get_test_portgroup()
del pdict['address'] del pdict['address']
response = self.post_json('/portgroups', pdict, expect_errors=True, self.post_json('/portgroups', pdict, headers=self.headers)
headers=self.headers) result = self.get_json('/portgroups/%s' % pdict['uuid'],
self.assertEqual(http_client.BAD_REQUEST, response.status_int) headers=self.headers)
self.assertEqual('application/json', response.content_type) self.assertIsNone(result['address'])
self.assertTrue(response.json['error_message'])
def test_create_portgroup_no_mandatory_field_node_uuid(self): def test_create_portgroup_no_mandatory_field_node_uuid(self):
pdict = apiutils.post_get_test_portgroup() pdict = apiutils.post_get_test_portgroup()
@ -999,6 +1053,32 @@ class TestPost(test_api_base.BaseApiTest):
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message']) 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)
response = self.post_json(
'/portgroups', pdict, expect_errors=True,
headers={api_base.Version.string: '1.25'})
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
def test_create_portgroup_mode_properties(self):
mode = '802.3ad'
props = {'bond_prop': 123}
pdict = apiutils.post_get_test_portgroup(mode=mode, properties=props)
self.post_json('/portgroups', pdict,
headers={api_base.Version.string: '1.26'})
portgroup = self.dbapi.get_portgroup_by_uuid(pdict['uuid'])
self.assertEqual((mode, props), (portgroup.mode, portgroup.properties))
def test_create_portgroup_default_mode(self):
pdict = apiutils.post_get_test_portgroup()
self.post_json('/portgroups', pdict,
headers={api_base.Version.string: '1.26'})
portgroup = self.dbapi.get_portgroup_by_uuid(pdict['uuid'])
self.assertEqual('active-backup', portgroup.mode)
@mock.patch.object(rpcapi.ConductorAPI, 'destroy_portgroup') @mock.patch.object(rpcapi.ConductorAPI, 'destroy_portgroup')
class TestDelete(test_api_base.BaseApiTest): class TestDelete(test_api_base.BaseApiTest):

View File

@ -107,6 +107,25 @@ class TestApiUtils(base.TestCase):
value = utils.is_path_removed(patch, path) value = utils.is_path_removed(patch, path)
self.assertFalse(value) self.assertFalse(value)
def test_is_path_updated_success(self):
patch = [{'path': '/name', 'op': 'remove'}]
path = '/name'
value = utils.is_path_updated(patch, path)
self.assertTrue(value)
def test_is_path_updated_subpath_success(self):
patch = [{'path': '/properties/switch_id', 'op': 'add', 'value': 'id'}]
path = '/properties'
value = utils.is_path_updated(patch, path)
self.assertTrue(value)
def test_is_path_updated_similar_subpath(self):
patch = [{'path': '/properties2/switch_id',
'op': 'replace', 'value': 'spam'}]
path = '/properties'
value = utils.is_path_updated(patch, path)
self.assertFalse(value)
def test_check_for_invalid_fields(self): def test_check_for_invalid_fields(self):
requested = ['field_1', 'field_3'] requested = ['field_1', 'field_3']
supported = ['field_1', 'field_2', 'field_3'] supported = ['field_1', 'field_2', 'field_3']
@ -158,6 +177,28 @@ class TestApiUtils(base.TestCase):
utils.check_allowed_fields, utils.check_allowed_fields,
['resource_class']) ['resource_class'])
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allowed_portgroup_fields_mode_properties(self,
mock_request):
mock_request.version.minor = 26
self.assertIsNone(
utils.check_allowed_portgroup_fields(['mode']))
self.assertIsNone(
utils.check_allowed_portgroup_fields(['properties']))
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allowed_portgroup_fields_mode_properties_fail(self,
mock_request):
mock_request.version.minor = 25
self.assertRaises(
exception.NotAcceptable,
utils.check_allowed_portgroup_fields,
['mode'])
self.assertRaises(
exception.NotAcceptable,
utils.check_allowed_portgroup_fields,
['properties'])
@mock.patch.object(pecan, 'request', spec_set=['version']) @mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allow_specify_driver(self, mock_request): def test_check_allow_specify_driver(self, mock_request):
mock_request.version.minor = 16 mock_request.version.minor = 16
@ -313,6 +354,13 @@ class TestApiUtils(base.TestCase):
mock_request.version.minor = 24 mock_request.version.minor = 24
self.assertFalse(utils.allow_remove_chassis_uuid()) self.assertFalse(utils.allow_remove_chassis_uuid())
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_allow_portgroup_mode_properties(self, mock_request):
mock_request.version.minor = 26
self.assertTrue(utils.allow_portgroup_mode_properties())
mock_request.version.minor = 25
self.assertFalse(utils.allow_portgroup_mode_properties())
class TestNodeIdent(base.TestCase): class TestNodeIdent(base.TestCase):

View File

@ -51,6 +51,7 @@ import sqlalchemy
import sqlalchemy.exc import sqlalchemy.exc
from ironic.common.i18n import _LE from ironic.common.i18n import _LE
from ironic.conf import CONF
from ironic.db.sqlalchemy import migration from ironic.db.sqlalchemy import migration
from ironic.db.sqlalchemy import models from ironic.db.sqlalchemy import models
from ironic.tests import base from ironic.tests import base
@ -601,6 +602,27 @@ class MigrationCheckersMixin(object):
self.assertIsInstance(targets.c.volume_id.type, self.assertIsInstance(targets.c.volume_id.type,
sqlalchemy.types.String) sqlalchemy.types.String)
def _pre_upgrade_493d8f27f235(self, engine):
portgroups = db_utils.get_table(engine, 'portgroups')
data = [{'uuid': uuidutils.generate_uuid()},
{'uuid': uuidutils.generate_uuid()}]
portgroups.insert().values(data).execute()
return data
def _check_493d8f27f235(self, engine, data):
portgroups = db_utils.get_table(engine, 'portgroups')
col_names = [column.name for column in portgroups.c]
self.assertIn('properties', col_names)
self.assertIsInstance(portgroups.c.properties.type,
sqlalchemy.types.TEXT)
self.assertIn('mode', col_names)
self.assertIsInstance(portgroups.c.mode.type,
sqlalchemy.types.String)
result = engine.execute(portgroups.select())
for row in result:
self.assertEqual(CONF.default_portgroup_mode, row['mode'])
def test_upgrade_and_version(self): def test_upgrade_and_version(self):
with patch_with_engine(self.engine): with patch_with_engine(self.engine):
self.migration_api.upgrade('head') self.migration_api.upgrade('head')

View File

@ -200,3 +200,14 @@ class DbportgroupTestCase(base.DbTestCase):
node_id=self.node.id, node_id=self.node.id,
name=str(uuidutils.generate_uuid()), name=str(uuidutils.generate_uuid()),
address='aa:bb:cc:33:11:22') address='aa:bb:cc:33:11:22')
def test_create_portgroup_no_mode(self):
self.config(default_portgroup_mode='802.3ad')
name = uuidutils.generate_uuid()
db_utils.create_test_portgroup(uuid=uuidutils.generate_uuid(),
node_id=self.node.id, name=name,
address='aa:bb:cc:dd:ee:ff')
res = self.dbapi.get_portgroup_by_id(self.portgroup.id)
self.assertEqual('active-backup', res.mode)
res = self.dbapi.get_portgroup_by_name(name)
self.assertEqual('802.3ad', res.mode)

View File

@ -465,6 +465,8 @@ def get_test_portgroup(**kw):
'internal_info': kw.get('internal_info', {"bar": "buzz"}), 'internal_info': kw.get('internal_info', {"bar": "buzz"}),
'standalone_ports_supported': kw.get('standalone_ports_supported', 'standalone_ports_supported': kw.get('standalone_ports_supported',
True), True),
'mode': kw.get('mode'),
'properties': kw.get('properties', {}),
} }

View File

@ -408,7 +408,7 @@ expected_object_fingerprints = {
'MyObj': '1.5-4f5efe8f0fcaf182bbe1c7fe3ba858db', 'MyObj': '1.5-4f5efe8f0fcaf182bbe1c7fe3ba858db',
'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905',
'Port': '1.6-609504503d68982a10f495659990084b', 'Port': '1.6-609504503d68982a10f495659990084b',
'Portgroup': '1.2-37b374b19bfd25db7e86aebc364e611e', 'Portgroup': '1.3-71923a81a86743b313b190f5c675e258',
'Conductor': '1.1-5091f249719d4a465062a1b3dc7f860d', 'Conductor': '1.1-5091f249719d4a465062a1b3dc7f860d',
'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370', 'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370',
'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d', 'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d',

View File

@ -0,0 +1,12 @@
---
features:
- Adds ``mode`` and ``properties`` fields in the portgroup object. Both of
them are optional and can be set from the API. They are available starting
with API microversion 1.26. If the ``mode`` field of a portgroup is not
specified in a POST request, its value will be set to the value of the
configuration option ``[DEFAULT]default_portgroup_mode``. The configuration
option ``[DEFAULT]default_portgroup_mode`` has a value of ``active-backup``
by default.
fixes:
- |
``address`` field of a portgroup is optional for all API microversions.