Merge "Add multitenancy-related fields to port API object"

This commit is contained in:
Jenkins 2016-07-13 23:42:51 +00:00 committed by Gerrit Code Review
commit 76726c6a3f
13 changed files with 458 additions and 22 deletions

View File

@ -32,6 +32,11 @@ always requests the newest supported API version.
API Versions History API Versions History
-------------------- --------------------
**1.19**
This API version adds the multitenancy-related ``local_link_connection``
and ``pxe_enabled`` fields to a port.
**1.18** **1.18**
Add ``internal_info`` readonly field to the port object, that will be used Add ``internal_info`` readonly field to the port object, that will be used

View File

@ -40,6 +40,11 @@ def hide_fields_in_newer_versions(obj):
# if requested version is < 1.18, hide internal_info field # if requested version is < 1.18, hide internal_info field
if not api_utils.allow_port_internal_info(): if not api_utils.allow_port_internal_info():
obj.internal_info = wsme.Unset obj.internal_info = wsme.Unset
# if requested version is < 1.19, hide local_link_connection and
# pxe_enabled fields
if not api_utils.allow_port_advanced_net_fields():
obj.pxe_enabled = wsme.Unset
obj.local_link_connection = wsme.Unset
class Port(base.APIBase): class Port(base.APIBase):
@ -90,6 +95,12 @@ class Port(base.APIBase):
mandatory=True) mandatory=True)
"""The UUID of the node this port belongs to""" """The UUID of the node this port belongs to"""
pxe_enabled = types.boolean
"""Indicates whether pxe is enabled or disabled on the node."""
local_link_connection = types.locallinkconnectiontype
"""The port binding profile for each port"""
links = wsme.wsattr([link.Link], readonly=True) links = wsme.wsattr([link.Link], readonly=True)
"""A list containing a self link and associated port links""" """A list containing a self link and associated port links"""
@ -151,7 +162,11 @@ class Port(base.APIBase):
extra={'foo': 'bar'}, extra={'foo': 'bar'},
internal_info={}, internal_info={},
created_at=datetime.datetime.utcnow(), created_at=datetime.datetime.utcnow(),
updated_at=datetime.datetime.utcnow()) updated_at=datetime.datetime.utcnow(),
pxe_enabled=True,
local_link_connection={
'switch_info': 'host', 'port_id': 'Gig0/1',
'switch_id': 'aa:bb:cc:dd:ee:ff'})
# NOTE(lucasagomes): node_uuid getter() method look at the # NOTE(lucasagomes): node_uuid getter() method look at the
# _node_uuid variable # _node_uuid variable
sample._node_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae' sample._node_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae'
@ -204,7 +219,9 @@ class PortsController(rest.RestController):
'detail': ['GET'], 'detail': ['GET'],
} }
invalid_sort_key_list = ['extra', 'internal_info'] invalid_sort_key_list = ['extra', 'internal_info', 'local_link_connection']
advanced_net_fields = ['pxe_enabled', 'local_link_connection']
def _get_ports_collection(self, node_ident, address, marker, limit, def _get_ports_collection(self, node_ident, address, marker, limit,
sort_key, sort_dir, resource_url=None, sort_key, sort_dir, resource_url=None,
@ -285,8 +302,13 @@ class PortsController(rest.RestController):
:param sort_dir: direction to sort. "asc" or "desc". Default: asc. :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
:param fields: Optional, a list with a specified set of fields :param fields: Optional, a list with a specified set of fields
of the resource to be returned. of the resource to be returned.
:raises: NotAcceptable
""" """
api_utils.check_allow_specify_fields(fields) api_utils.check_allow_specify_fields(fields)
if (fields and not api_utils.allow_port_advanced_net_fields() and
set(fields).intersection(self.advanced_net_fields)):
raise exception.NotAcceptable()
if fields is None: if fields is None:
fields = _DEFAULT_RETURN_FIELDS fields = _DEFAULT_RETURN_FIELDS
@ -322,6 +344,7 @@ class PortsController(rest.RestController):
:param limit: maximum number of resources to return in a single result. :param limit: maximum number of resources to return in a single result.
:param sort_key: column to sort results by. Default: id. :param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc. :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
:raises: NotAcceptable, HTTPNotFound
""" """
if not node_uuid and node: if not node_uuid and node:
# We're invoking this interface using positional notation, or # We're invoking this interface using positional notation, or
@ -348,6 +371,7 @@ class PortsController(rest.RestController):
:param port_uuid: UUID of a port. :param port_uuid: UUID of a port.
:param fields: Optional, a list with a specified set of fields :param fields: Optional, a list with a specified set of fields
of the resource to be returned. of the resource to be returned.
:raises: NotAcceptable
""" """
if self.from_nodes: if self.from_nodes:
raise exception.OperationNotPermitted() raise exception.OperationNotPermitted()
@ -362,12 +386,19 @@ class PortsController(rest.RestController):
"""Create a new port. """Create a new port.
:param port: a port within the request body. :param port: a port within the request body.
:raises: NotAcceptable
""" """
if self.from_nodes: if self.from_nodes:
raise exception.OperationNotPermitted() raise exception.OperationNotPermitted()
pdict = port.as_dict()
if not api_utils.allow_port_advanced_net_fields():
if set(pdict).intersection(self.advanced_net_fields):
raise exception.NotAcceptable()
new_port = objects.Port(pecan.request.context, new_port = objects.Port(pecan.request.context,
**port.as_dict()) **pdict)
new_port.create() new_port.create()
# Set the HTTP Location Header # Set the HTTP Location Header
pecan.response.location = link.build_url('ports', new_port.uuid) pecan.response.location = link.build_url('ports', new_port.uuid)
@ -380,9 +411,16 @@ class PortsController(rest.RestController):
:param port_uuid: UUID of a port. :param port_uuid: UUID of a port.
:param patch: a json PATCH document to apply to this port. :param patch: a json PATCH document to apply to this port.
:raises: NotAcceptable
""" """
if self.from_nodes: if self.from_nodes:
raise exception.OperationNotPermitted() raise exception.OperationNotPermitted()
if not api_utils.allow_port_advanced_net_fields():
for field in self.advanced_net_fields:
field_path = '/%s' % field
if (api_utils.get_patch_values(patch, field_path) or
api_utils.is_path_removed(patch, field_path)):
raise exception.NotAcceptable()
rpc_port = objects.Port.get_by_uuid(pecan.request.context, port_uuid) rpc_port = objects.Port.get_by_uuid(pecan.request.context, port_uuid)
try: try:

View File

@ -255,3 +255,75 @@ class JsonPatchType(wtypes.Base):
if patch.value is not wsme.Unset: if patch.value is not wsme.Unset:
ret['value'] = patch.value ret['value'] = patch.value
return ret return ret
class LocalLinkConnectionType(wtypes.UserType):
"""A type describing local link connection."""
basetype = wtypes.DictType
name = 'locallinkconnection'
mandatory_fields = {'switch_id',
'port_id'}
valid_fields = mandatory_fields.union({'switch_info'})
@staticmethod
def validate(value):
"""Validate and convert the input to a LocalLinkConnectionType.
:param value: A dictionary of values to validate, switch_id is a MAC
address or an OpenFlow based datapath_id, switch_info is an optional
field.
For example::
{
'switch_id': mac_or_datapath_id(),
'port_id': 'Ethernet3/1',
'switch_info': 'switch1'
}
:returns: A dictionary.
:raises: Invalid if some of the keys in the dictionary being validated
are unknown, invalid, or some required ones are missing.
"""
wtypes.DictType(wtypes.text, wtypes.text).validate(value)
keys = set(value)
# This is to workaround an issue when an API object is initialized from
# RPC object, in which dictionary fields that are set to None become
# empty dictionaries
if not keys:
return value
invalid = keys - LocalLinkConnectionType.valid_fields
if invalid:
raise exception.Invalid(_('%s are invalid keys') % (invalid))
# Check all mandatory fields are present
missing = LocalLinkConnectionType.mandatory_fields - keys
if missing:
msg = _('Missing mandatory keys: %s') % missing
raise exception.Invalid(msg)
# Check switch_id is either a valid mac address or
# OpenFlow datapath_id and normalize it.
if utils.is_valid_mac(value['switch_id']):
value['switch_id'] = utils.validate_and_normalize_mac(
value['switch_id'])
elif utils.is_valid_datapath_id(value['switch_id']):
value['switch_id'] = utils.validate_and_normalize_datapath_id(
value['switch_id'])
else:
raise exception.InvalidSwitchID(switch_id=value['switch_id'])
return value
@staticmethod
def frombasetype(value):
if value is None:
return None
return LocalLinkConnectionType.validate(value)
locallinkconnectiontype = LocalLinkConnectionType()

View File

@ -99,6 +99,20 @@ def get_patch_values(patch, path):
if p['path'] == path and p['op'] != 'remove'] if p['path'] == path and p['op'] != 'remove']
def is_path_removed(patch, path):
"""Returns whether the patch includes removal of the path (or subpath of).
:param patch: HTTP PATCH request body.
:param path: the path to check.
:returns: True if path or subpath being removed, False otherwise.
"""
path = path.rstrip('/')
for p in patch:
if ((p['path'] == path or p['path'].startswith(path + '/')) and
p['op'] == 'remove'):
return True
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
@ -299,6 +313,15 @@ def allow_port_internal_info():
versions.MINOR_18_PORT_INTERNAL_INFO) versions.MINOR_18_PORT_INTERNAL_INFO)
def allow_port_advanced_net_fields():
"""Check if we should return local_link_connection and pxe_enabled fields.
Version 1.19 of the API added support for these new fields in port object.
"""
return (pecan.request.version.minor >=
versions.MINOR_19_PORT_ADVANCED_NET_FIELDS)
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

@ -48,6 +48,7 @@ BASE_VERSION = 1
# v1.16: Add ability to filter nodes by driver. # v1.16: Add ability to filter nodes by driver.
# v1.17: Add 'adopt' verb for ADOPTING active nodes. # v1.17: Add 'adopt' verb for ADOPTING active nodes.
# v1.18: Add port.internal_info. # v1.18: Add port.internal_info.
# v1.19: Add port.local_link_connection and port.pxe_enabled.
MINOR_0_JUNO = 0 MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1 MINOR_1_INITIAL_VERSION = 1
@ -68,11 +69,12 @@ MINOR_15_MANUAL_CLEAN = 15
MINOR_16_DRIVER_FILTER = 16 MINOR_16_DRIVER_FILTER = 16
MINOR_17_ADOPT_VERB = 17 MINOR_17_ADOPT_VERB = 17
MINOR_18_PORT_INTERNAL_INFO = 18 MINOR_18_PORT_INTERNAL_INFO = 18
MINOR_19_PORT_ADVANCED_NET_FIELDS = 19
# When adding another version, update MINOR_MAX_VERSION and also update # When adding another version, update MINOR_MAX_VERSION and also update
# doc/source/webapi/v1.rst with a detailed explanation of what the version has # doc/source/webapi/v1.rst with a detailed explanation of what the version has
# changed. # changed.
MINOR_MAX_VERSION = MINOR_18_PORT_INTERNAL_INFO MINOR_MAX_VERSION = MINOR_19_PORT_ADVANCED_NET_FIELDS
# 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

@ -192,6 +192,16 @@ class InvalidMAC(Invalid):
_msg_fmt = _("Expected a MAC address but received %(mac)s.") _msg_fmt = _("Expected a MAC address but received %(mac)s.")
class InvalidSwitchID(Invalid):
_msg_fmt = _("Expected a MAC address or OpenFlow datapath ID but "
"received %(switch_id)s.")
class InvalidDatapathId(Invalid):
_msg_fmt = _("Expected an OpenFlow datapath ID but received "
"%(datapath_id)s.")
class InvalidStateRequested(Invalid): class InvalidStateRequested(Invalid):
_msg_fmt = _('The requested action "%(action)s" can not be performed ' _msg_fmt = _('The requested action "%(action)s" can not be performed '
'on node "%(node)s" while it is in state "%(state)s".') 'on node "%(node)s" while it is in state "%(state)s".')

View File

@ -185,6 +185,22 @@ def is_valid_mac(address):
re.match(m, address.lower())) re.match(m, address.lower()))
def is_valid_datapath_id(datapath_id):
"""Verify the format of an OpenFlow datapath_id.
Check if a datapath_id is valid and contains 16 hexadecimal digits.
Datapath ID format: the lower 48-bits are for a MAC address,
while the upper 16-bits are implementer-defined.
:param datapath_id: OpenFlow datapath_id to be validated.
:returns: True if valid. False if not.
"""
m = "^[0-9a-f]{16}$"
return (isinstance(datapath_id, six.string_types) and
re.match(m, datapath_id.lower()))
_is_valid_logical_name_re = re.compile(r'^[A-Z0-9-._~]+$', re.I) _is_valid_logical_name_re = re.compile(r'^[A-Z0-9-._~]+$', re.I)
# old is_hostname_safe() regex, retained for backwards compat # old is_hostname_safe() regex, retained for backwards compat
@ -284,6 +300,23 @@ def validate_and_normalize_mac(address):
return address.lower() return address.lower()
def validate_and_normalize_datapath_id(datapath_id):
"""Validate an OpenFlow datapath_id and return normalized form.
Checks whether the supplied OpenFlow datapath_id is formally correct and
normalize it to all lower case.
:param datapath_id: OpenFlow datapath_id to be validated and normalized.
:returns: Normalized and validated OpenFlow datapath_id.
:raises: InvalidDatapathId If an OpenFlow datapath_id is not valid.
"""
if not is_valid_datapath_id(datapath_id):
raise exception.InvalidDatapathId(datapath_id=datapath_id)
return datapath_id.lower()
def is_valid_ipv6_cidr(address): def is_valid_ipv6_cidr(address):
try: try:
str(netaddr.IPNetwork(address, version=6).cidr) str(netaddr.IPNetwork(address, version=6).cidr)

View File

@ -105,9 +105,6 @@ def port_post_data(**kw):
port = utils.get_test_port(**kw) port = utils.get_test_port(**kw)
# node_id is not part of the API object # node_id is not part of the API object
port.pop('node_id') port.pop('node_id')
# TODO(vsaienko): remove when API part is added
port.pop('local_link_connection')
port.pop('pxe_enabled')
# portgroup_id is not part of the API object # portgroup_id is not part of the API object
port.pop('portgroup_id') port.pop('portgroup_id')
internal = port_controller.PortPatchType.internal_attrs() internal = port_controller.PortPatchType.internal_attrs()

View File

@ -31,6 +31,7 @@ from ironic.api.controllers import base as api_base
from ironic.api.controllers import v1 as api_v1 from ironic.api.controllers import v1 as api_v1
from ironic.api.controllers.v1 import port as api_port from ironic.api.controllers.v1 import port as api_port
from ironic.api.controllers.v1 import utils as api_utils from ironic.api.controllers.v1 import utils as api_utils
from ironic.api.controllers.v1 import versions
from ironic.common import exception from ironic.common import exception
from ironic.conductor import rpcapi from ironic.conductor import rpcapi
from ironic.tests import base from ironic.tests import base
@ -63,6 +64,7 @@ class TestListPorts(test_api_base.BaseApiTest):
def setUp(self): def setUp(self):
super(TestListPorts, self).setUp() super(TestListPorts, self).setUp()
self.node = obj_utils.create_test_node(self.context) self.node = obj_utils.create_test_node(self.context)
self.headers = {api_base.Version.string: str(api_v1.MAX_VER)}
def test_empty(self): def test_empty(self):
data = self.get_json('/ports') data = self.get_json('/ports')
@ -145,7 +147,7 @@ class TestListPorts(test_api_base.BaseApiTest):
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int) self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
def test_detail(self): def test_detail(self):
port = obj_utils.create_test_port(self.context, node_id=self.node.id,) port = obj_utils.create_test_port(self.context, node_id=self.node.id)
data = self.get_json( data = self.get_json(
'/ports/detail', '/ports/detail',
headers={api_base.Version.string: str(api_v1.MAX_VER)} headers={api_base.Version.string: str(api_v1.MAX_VER)}
@ -154,6 +156,8 @@ class TestListPorts(test_api_base.BaseApiTest):
self.assertIn('extra', data['ports'][0]) self.assertIn('extra', data['ports'][0])
self.assertIn('internal_info', data['ports'][0]) self.assertIn('internal_info', data['ports'][0])
self.assertIn('node_uuid', data['ports'][0]) self.assertIn('node_uuid', data['ports'][0])
self.assertIn('pxe_enabled', data['ports'][0])
self.assertIn('local_link_connection', data['ports'][0])
# never expose the node_id # never expose the node_id
self.assertNotIn('node_id', data['ports'][0]) self.assertNotIn('node_id', data['ports'][0])
@ -373,6 +377,8 @@ class TestPatch(test_api_base.BaseApiTest):
self.mock_gtf = p.start() self.mock_gtf = p.start()
self.mock_gtf.return_value = 'test-topic' self.mock_gtf.return_value = 'test-topic'
self.addCleanup(p.stop) self.addCleanup(p.stop)
self.headers = {api_base.Version.string: str(
versions.MAX_VERSION_STRING)}
def test_update_byid(self, mock_upd): def test_update_byid(self, mock_upd):
extra = {'foo': 'bar'} extra = {'foo': 'bar'}
@ -456,6 +462,44 @@ class TestPatch(test_api_base.BaseApiTest):
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code) self.assertEqual(http_client.OK, response.status_code)
def test_replace_local_link_connection(self, mock_upd):
switch_id = 'aa:bb:cc:dd:ee:ff'
mock_upd.return_value = self.port
mock_upd.return_value.local_link_connection['switch_id'] = switch_id
response = self.patch_json('/ports/%s' % self.port.uuid,
[{'path':
'/local_link_connection/switch_id',
'value': switch_id,
'op': 'replace'}],
headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
self.assertEqual(switch_id,
response.json['local_link_connection']['switch_id'])
self.assertTrue(mock_upd.called)
kargs = mock_upd.call_args[0][1]
self.assertEqual(switch_id, kargs.local_link_connection['switch_id'])
def test_remove_local_link_connection_old_api(self, mock_upd):
response = self.patch_json(
'/ports/%s' % self.port.uuid,
[{'path': '/local_link_connection/switch_id', 'op': 'remove'}],
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
def test_set_pxe_enabled_false_old_api(self, mock_upd):
response = self.patch_json('/ports/%s' % self.port.uuid,
[{'path': '/pxe_enabled',
'value': False,
'op': 'add'}],
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
def test_add_node_uuid(self, mock_upd): def test_add_node_uuid(self, mock_upd):
mock_upd.return_value = self.port mock_upd.return_value = self.port
response = self.patch_json('/ports/%s' % self.port.uuid, response = self.patch_json('/ports/%s' % self.port.uuid,
@ -661,21 +705,50 @@ class TestPatch(test_api_base.BaseApiTest):
kargs = mock_upd.call_args[0][1] kargs = mock_upd.call_args[0][1]
self.assertEqual(address.lower(), kargs.address) self.assertEqual(address.lower(), kargs.address)
def test_update_pxe_enabled_allowed(self, mock_upd):
pxe_enabled = True
mock_upd.return_value = self.port
mock_upd.return_value.pxe_enabled = pxe_enabled
response = self.patch_json('/ports/%s' % self.port.uuid,
[{'path': '/pxe_enabled',
'value': pxe_enabled,
'op': 'replace'}],
headers=self.headers)
self.assertEqual(http_client.OK, response.status_code)
self.assertEqual(pxe_enabled, response.json['pxe_enabled'])
def test_update_pxe_enabled_old_api_version(self, mock_upd):
pxe_enabled = True
mock_upd.return_value = self.port
headers = {api_base.Version.string: '1.14'}
response = self.patch_json('/ports/%s' % self.port.uuid,
[{'path': '/pxe_enabled',
'value': pxe_enabled,
'op': 'replace'}],
expect_errors=True,
headers=headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
self.assertFalse(mock_upd.called)
class TestPost(test_api_base.BaseApiTest): class TestPost(test_api_base.BaseApiTest):
def setUp(self): def setUp(self):
super(TestPost, self).setUp() super(TestPost, self).setUp()
self.node = obj_utils.create_test_node(self.context) self.node = obj_utils.create_test_node(self.context)
self.headers = {api_base.Version.string: str(
versions.MAX_VERSION_STRING)}
@mock.patch.object(timeutils, 'utcnow') @mock.patch.object(timeutils, 'utcnow')
def test_create_port(self, mock_utcnow): def test_create_port(self, mock_utcnow):
pdict = post_get_test_port() pdict = post_get_test_port()
test_time = datetime.datetime(2000, 1, 1, 0, 0) test_time = datetime.datetime(2000, 1, 1, 0, 0)
mock_utcnow.return_value = test_time mock_utcnow.return_value = test_time
response = self.post_json('/ports', pdict) response = self.post_json('/ports', pdict, headers=self.headers)
self.assertEqual(http_client.CREATED, response.status_int) self.assertEqual(http_client.CREATED, response.status_int)
result = self.get_json('/ports/%s' % pdict['uuid']) result = self.get_json('/ports/%s' % pdict['uuid'],
headers=self.headers)
self.assertEqual(pdict['uuid'], result['uuid']) self.assertEqual(pdict['uuid'], result['uuid'])
self.assertFalse(result['updated_at']) self.assertFalse(result['updated_at'])
return_created_at = timeutils.parse_isotime( return_created_at = timeutils.parse_isotime(
@ -691,8 +764,9 @@ class TestPost(test_api_base.BaseApiTest):
with mock.patch.object(self.dbapi, 'create_port', with mock.patch.object(self.dbapi, 'create_port',
wraps=self.dbapi.create_port) as cp_mock: wraps=self.dbapi.create_port) as cp_mock:
pdict = post_get_test_port(extra={'foo': 123}) pdict = post_get_test_port(extra={'foo': 123})
self.post_json('/ports', pdict) self.post_json('/ports', pdict, headers=self.headers)
result = self.get_json('/ports/%s' % pdict['uuid']) result = self.get_json('/ports/%s' % pdict['uuid'],
headers=self.headers)
self.assertEqual(pdict['extra'], result['extra']) self.assertEqual(pdict['extra'], result['extra'])
cp_mock.assert_called_once_with(mock.ANY) cp_mock.assert_called_once_with(mock.ANY)
# Check that 'id' is not in first arg of positional args # Check that 'id' is not in first arg of positional args
@ -701,8 +775,9 @@ class TestPost(test_api_base.BaseApiTest):
def test_create_port_generate_uuid(self): def test_create_port_generate_uuid(self):
pdict = post_get_test_port() pdict = post_get_test_port()
del pdict['uuid'] del pdict['uuid']
response = self.post_json('/ports', pdict) response = self.post_json('/ports', pdict, headers=self.headers)
result = self.get_json('/ports/%s' % response.json['uuid']) result = self.get_json('/ports/%s' % response.json['uuid'],
headers=self.headers)
self.assertEqual(pdict['address'], result['address']) self.assertEqual(pdict['address'], result['address'])
self.assertTrue(uuidutils.is_uuid_like(result['uuid'])) self.assertTrue(uuidutils.is_uuid_like(result['uuid']))
@ -711,14 +786,16 @@ class TestPost(test_api_base.BaseApiTest):
'float': 0.1, 'bool': True, 'float': 0.1, 'bool': True,
'list': [1, 2], 'none': None, 'list': [1, 2], 'none': None,
'dict': {'cat': 'meow'}}) 'dict': {'cat': 'meow'}})
self.post_json('/ports', pdict) self.post_json('/ports', pdict, headers=self.headers)
result = self.get_json('/ports/%s' % pdict['uuid']) result = self.get_json('/ports/%s' % pdict['uuid'],
headers=self.headers)
self.assertEqual(pdict['extra'], result['extra']) self.assertEqual(pdict['extra'], result['extra'])
def test_create_port_no_mandatory_field_address(self): def test_create_port_no_mandatory_field_address(self):
pdict = post_get_test_port() pdict = post_get_test_port()
del pdict['address'] del pdict['address']
response = self.post_json('/ports', pdict, expect_errors=True) response = self.post_json('/ports', pdict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int) self.assertEqual(http_client.BAD_REQUEST, response.status_int)
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'])
@ -741,8 +818,9 @@ class TestPost(test_api_base.BaseApiTest):
def test_create_port_address_normalized(self): def test_create_port_address_normalized(self):
address = 'AA:BB:CC:DD:EE:FF' address = 'AA:BB:CC:DD:EE:FF'
pdict = post_get_test_port(address=address) pdict = post_get_test_port(address=address)
self.post_json('/ports', pdict) self.post_json('/ports', pdict, headers=self.headers)
result = self.get_json('/ports/%s' % pdict['uuid']) result = self.get_json('/ports/%s' % pdict['uuid'],
headers=self.headers)
self.assertEqual(address.lower(), result['address']) self.assertEqual(address.lower(), result['address'])
def test_create_port_with_hyphens_delimiter(self): def test_create_port_with_hyphens_delimiter(self):
@ -764,7 +842,7 @@ class TestPost(test_api_base.BaseApiTest):
def test_node_uuid_to_node_id_mapping(self): def test_node_uuid_to_node_id_mapping(self):
pdict = post_get_test_port(node_uuid=self.node['uuid']) pdict = post_get_test_port(node_uuid=self.node['uuid'])
self.post_json('/ports', pdict) self.post_json('/ports', pdict, headers=self.headers)
# GET doesn't return the node_id it's an internal value # GET doesn't return the node_id it's an internal value
port = self.dbapi.get_port_by_uuid(pdict['uuid']) port = self.dbapi.get_port_by_uuid(pdict['uuid'])
self.assertEqual(self.node['id'], port.node_id) self.assertEqual(self.node['id'], port.node_id)
@ -780,9 +858,10 @@ class TestPost(test_api_base.BaseApiTest):
def test_create_port_address_already_exist(self): def test_create_port_address_already_exist(self):
address = 'AA:AA:AA:11:22:33' address = 'AA:AA:AA:11:22:33'
pdict = post_get_test_port(address=address) pdict = post_get_test_port(address=address)
self.post_json('/ports', pdict) self.post_json('/ports', pdict, headers=self.headers)
pdict['uuid'] = uuidutils.generate_uuid() pdict['uuid'] = uuidutils.generate_uuid()
response = self.post_json('/ports', pdict, expect_errors=True) response = self.post_json('/ports', pdict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.CONFLICT, response.status_int) self.assertEqual(http_client.CONFLICT, response.status_int)
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
error_msg = response.json['error_message'] error_msg = response.json['error_message']
@ -797,6 +876,74 @@ class TestPost(test_api_base.BaseApiTest):
self.assertEqual(http_client.BAD_REQUEST, response.status_int) self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertTrue(response.json['error_message']) self.assertTrue(response.json['error_message'])
def test_create_port_some_invalid_local_link_connection_key(self):
pdict = post_get_test_port(
local_link_connection={'switch_id': 'value1',
'port_id': 'Ethernet1/15',
'switch_foo': 'value3'})
response = self.post_json('/ports', pdict, 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'])
def test_create_port_local_link_connection_keys(self):
pdict = post_get_test_port(
local_link_connection={'switch_id': '0a:1b:2c:3d:4e:5f',
'port_id': 'Ethernet1/15',
'switch_info': 'value3'})
response = self.post_json('/ports', pdict, headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.CREATED, response.status_int)
def test_create_port_local_link_connection_switch_id_bad_mac(self):
pdict = post_get_test_port(
local_link_connection={'switch_id': 'zz:zz:zz:zz:zz:zz',
'port_id': 'Ethernet1/15',
'switch_info': 'value3'})
response = self.post_json('/ports', pdict, 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'])
def test_create_port_local_link_connection_missing_mandatory(self):
pdict = post_get_test_port(
local_link_connection={'switch_id': '0a:1b:2c:3d:4e:5f',
'switch_info': 'fooswitch'})
response = self.post_json('/ports', pdict, expect_errors=True,
headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_create_port_local_link_connection_missing_optional(self):
pdict = post_get_test_port(
local_link_connection={'switch_id': '0a:1b:2c:3d:4e:5f',
'port_id': 'Ethernet1/15'})
response = self.post_json('/ports', pdict, headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.CREATED, response.status_int)
def test_create_port_with_llc_old_api_version(self):
headers = {api_base.Version.string: '1.14'}
pdict = post_get_test_port(
local_link_connection={'switch_id': '0a:1b:2c:3d:4e:5f',
'port_id': 'Ethernet1/15'})
response = self.post_json('/ports', pdict, headers=headers,
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
def test_create_port_with_pxe_enabled_old_api_version(self):
headers = {api_base.Version.string: '1.14'}
pdict = post_get_test_port(
pxe_enabled=False)
del pdict['local_link_connection']
response = self.post_json('/ports', pdict, headers=headers,
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
@mock.patch.object(rpcapi.ConductorAPI, 'destroy_port') @mock.patch.object(rpcapi.ConductorAPI, 'destroy_port')
class TestDelete(test_api_base.BaseApiTest): class TestDelete(test_api_base.BaseApiTest):

View File

@ -287,3 +287,55 @@ class TestListType(base.TestCase):
self.assertItemsEqual(['foo', 'bar'], self.assertItemsEqual(['foo', 'bar'],
v.validate("foo,foo,foo,bar")) v.validate("foo,foo,foo,bar"))
self.assertIsInstance(v.validate('foo,bar'), list) self.assertIsInstance(v.validate('foo,bar'), list)
class TestLocalLinkConnectionType(base.TestCase):
def test_local_link_connection_type(self):
v = types.locallinkconnectiontype
value = {'switch_id': '0a:1b:2c:3d:4e:5f',
'port_id': 'value2',
'switch_info': 'value3'}
self.assertItemsEqual(value, v.validate(value))
def test_local_link_connection_type_datapath_id(self):
v = types.locallinkconnectiontype
value = {'switch_id': '0000000000000000',
'port_id': 'value2',
'switch_info': 'value3'}
self.assertItemsEqual(value,
v.validate(value))
def test_local_link_connection_type_not_mac_or_datapath_id(self):
v = types.locallinkconnectiontype
value = {'switch_id': 'badid',
'port_id': 'value2',
'switch_info': 'value3'}
self.assertRaises(exception.InvalidSwitchID, v.validate, value)
def test_local_link_connection_type_invalid_key(self):
v = types.locallinkconnectiontype
value = {'switch_id': '0a:1b:2c:3d:4e:5f',
'port_id': 'value2',
'switch_info': 'value3',
'invalid_key': 'value'}
self.assertRaisesRegex(exception.Invalid, 'are invalid keys',
v.validate, value)
def test_local_link_connection_type_missing_mandatory_key(self):
v = types.locallinkconnectiontype
value = {'switch_id': '0a:1b:2c:3d:4e:5f',
'switch_info': 'value3'}
self.assertRaisesRegex(exception.Invalid, 'Missing mandatory',
v.validate, value)
def test_local_link_connection_type_withou_optional_key(self):
v = types.locallinkconnectiontype
value = {'switch_id': '0a:1b:2c:3d:4e:5f',
'port_id': 'value2'}
self.assertItemsEqual(value, v.validate(value))
def test_local_link_connection_type_empty_value(self):
v = types.locallinkconnectiontype
value = {}
self.assertItemsEqual(value, v.validate(value))

View File

@ -82,6 +82,31 @@ class TestApiUtils(base.TestCase):
values = utils.get_patch_values(patch, path) values = utils.get_patch_values(patch, path)
self.assertEqual(['node-x', 'node-y'], values) self.assertEqual(['node-x', 'node-y'], values)
def test_is_path_removed_success(self):
patch = [{'path': '/name', 'op': 'remove'}]
path = '/name'
value = utils.is_path_removed(patch, path)
self.assertTrue(value)
def test_is_path_removed_subpath_success(self):
patch = [{'path': '/local_link_connection/switch_id', 'op': 'remove'}]
path = '/local_link_connection'
value = utils.is_path_removed(patch, path)
self.assertTrue(value)
def test_is_path_removed_similar_subpath(self):
patch = [{'path': '/local_link_connection_info/switch_id',
'op': 'remove'}]
path = '/local_link_connection'
value = utils.is_path_removed(patch, path)
self.assertFalse(value)
def test_is_path_removed_replace(self):
patch = [{'path': '/name', 'op': 'replace', 'value': 'node-x'}]
path = '/name'
value = utils.is_path_removed(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']
@ -200,6 +225,13 @@ class TestApiUtils(base.TestCase):
mock_request.version.minor = 17 mock_request.version.minor = 17
self.assertFalse(utils.allow_port_internal_info()) self.assertFalse(utils.allow_port_internal_info())
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_allow_multitenancy_fields(self, mock_request):
mock_request.version.minor = 19
self.assertTrue(utils.allow_port_advanced_net_fields())
mock_request.version.minor = 18
self.assertFalse(utils.allow_port_advanced_net_fields())
class TestNodeIdent(base.TestCase): class TestNodeIdent(base.TestCase):

View File

@ -385,6 +385,14 @@ class GenericUtilsTestCase(base.TestCase):
self.assertFalse(utils.is_valid_mac("AA BB CC DD EE FF")) self.assertFalse(utils.is_valid_mac("AA BB CC DD EE FF"))
self.assertFalse(utils.is_valid_mac("AA-BB-CC-DD-EE-FF")) self.assertFalse(utils.is_valid_mac("AA-BB-CC-DD-EE-FF"))
def test_is_valid_datapath_id(self):
self.assertTrue(utils.is_valid_datapath_id("525400cf2d319fdf"))
self.assertTrue(utils.is_valid_datapath_id("525400CF2D319FDF"))
self.assertFalse(utils.is_valid_datapath_id("52"))
self.assertFalse(utils.is_valid_datapath_id("52:54:00:cf:2d:31"))
self.assertFalse(utils.is_valid_datapath_id("notadatapathid00"))
self.assertFalse(utils.is_valid_datapath_id("5525400CF2D319FDF"))
def test_is_hostname_safe(self): def test_is_hostname_safe(self):
self.assertTrue(utils.is_hostname_safe('spam')) self.assertTrue(utils.is_hostname_safe('spam'))
self.assertFalse(utils.is_hostname_safe('spAm')) self.assertFalse(utils.is_hostname_safe('spAm'))
@ -456,6 +464,15 @@ class GenericUtilsTestCase(base.TestCase):
self.assertEqual(mac.lower(), self.assertEqual(mac.lower(),
utils.validate_and_normalize_mac(mac)) utils.validate_and_normalize_mac(mac))
def test_validate_and_normalize_datapath_id(self):
datapath_id = 'AA:BB:CC:DD:EE:FF'
with mock.patch.object(utils, 'is_valid_datapath_id',
autospec=True) as m_mock:
m_mock.return_value = True
self.assertEqual(datapath_id.lower(),
utils.validate_and_normalize_datapath_id(
datapath_id))
def test_validate_and_normalize_mac_invalid_format(self): def test_validate_and_normalize_mac_invalid_format(self):
with mock.patch.object(utils, 'is_valid_mac', autospec=True) as m_mock: with mock.patch.object(utils, 'is_valid_mac', autospec=True) as m_mock:
m_mock.return_value = False m_mock.return_value = False

View File

@ -0,0 +1,8 @@
---
features:
- |
API version is bumped to 1.19, ``local_link_connection`` and
``pxe_enabled`` fields were added to a Port:
* ``pxe_enabled`` indicates whether PXE is enabled for the port.
* ``local_link_connection`` contains the port binding profile.