diff --git a/neutron_lib/exceptions/__init__.py b/neutron_lib/exceptions/__init__.py index 67b4e07e4..5f75ddc90 100644 --- a/neutron_lib/exceptions/__init__.py +++ b/neutron_lib/exceptions/__init__.py @@ -539,3 +539,17 @@ class InvalidServiceType(InvalidInput): :param service_type: The service type that's invalid. """ message = _("Invalid service type: %(service_type)s.") + + +class NetworkVlanRangeError(NeutronException): + message = _("Invalid network VLAN range: '%(vlan_range)s' - '%(error)s'.") + + def __init__(self, **kwargs): + # Convert vlan_range tuple to 'start:end' format for display + if isinstance(kwargs['vlan_range'], tuple): + kwargs['vlan_range'] = "%d:%d" % kwargs['vlan_range'] + super(NetworkVlanRangeError, self).__init__(**kwargs) + + +class PhysicalNetworkNameError(NeutronException): + message = _("Empty physical network name.") diff --git a/neutron_lib/plugins/utils.py b/neutron_lib/plugins/utils.py new file mode 100644 index 000000000..72de9a300 --- /dev/null +++ b/neutron_lib/plugins/utils.py @@ -0,0 +1,288 @@ +# Copyright 2013 Cisco Systems, Inc. +# +# 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. + +import collections +import contextlib +import hashlib + +from oslo_log import log as logging +from oslo_utils import encodeutils +from oslo_utils import excutils + +from neutron_lib._i18n import _ +from neutron_lib import constants +from neutron_lib import exceptions + + +LOG = logging.getLogger(__name__) +INTERFACE_HASH_LEN = 6 + + +def _is_valid_range(val, min, max): + try: + # NOTE: use str value to not permit booleans + val = int(str(val)) + return min <= val <= max + except (ValueError, TypeError): + return False + + +def is_valid_vlan_tag(vlan): + """Validate a VLAN tag. + + :param vlan: The VLAN tag to validate. + :returns: True if vlan is a number that is a valid VLAN tag. + """ + return _is_valid_range( + vlan, constants.MIN_VLAN_TAG, constants.MAX_VLAN_TAG) + + +def is_valid_gre_id(gre_id): + """Validate a GRE ID. + + :param gre_id: The GRE ID to validate. + :returns: True if gre_id is a number that's a valid GRE ID. + """ + return _is_valid_range( + gre_id, constants.MIN_GRE_ID, constants.MAX_GRE_ID) + + +def is_valid_vxlan_vni(vni): + """Validate a VXLAN VNI. + + :param vni: The VNI to validate. + :returns: True if vni is a number that's a valid VXLAN VNI. + """ + return _is_valid_range( + vni, constants.MIN_VXLAN_VNI, constants.MAX_VXLAN_VNI) + + +def is_valid_geneve_vni(vni): + """Validate a Geneve VNI + + :param vni: The VNI to validate. + :returns: True if vni is a number that's a valid Geneve VNI. + """ + return _is_valid_range( + vni, constants.MIN_GENEVE_VNI, constants.MAX_GENEVE_VNI) + + +_TUNNEL_MAPPINGS = { + constants.TYPE_GRE: is_valid_gre_id, + constants.TYPE_VXLAN: is_valid_vxlan_vni, + constants.TYPE_GENEVE: is_valid_geneve_vni +} + + +def verify_tunnel_range(tunnel_range, tunnel_type): + """Verify a given tunnel range is valid given it's tunnel type. + + Existing validation is done for GRE, VXLAN and GENEVE types as per + _TUNNEL_MAPPINGS. + + :param tunnel_range: An iterable who's 0 index is the min tunnel range + and who's 1 index is the max tunnel range. + :param tunnel_type: The tunnel type of the range. + :returns: None if the tunnel_range is valid. + :raises: NetworkTunnelRangeError if tunnel_range is invalid. + """ + if tunnel_type in _TUNNEL_MAPPINGS: + for ident in tunnel_range: + if not _TUNNEL_MAPPINGS[tunnel_type](ident): + raise exceptions.NetworkTunnelRangeError( + tunnel_range=tunnel_range, + error=_("%(id)s is not a valid %(type)s identifier") % + {'id': ident, 'type': tunnel_type}) + if tunnel_range[1] < tunnel_range[0]: + raise exceptions.NetworkTunnelRangeError( + tunnel_range=tunnel_range, + error=_("End of tunnel range is less " + "than start of tunnel range")) + + +def _raise_invalid_tag(vlan_str, vlan_range): + """Raise an exception for invalid tag.""" + raise exceptions.NetworkVlanRangeError( + vlan_range=vlan_range, + error=_("%s is not a valid VLAN tag") % vlan_str) + + +def verify_vlan_range(vlan_range): + """Verify a VLAN range is valid. + + :param vlan_range: An iterable who's 0 index is the min tunnel range + and who's 1 index is the max tunnel range. + :returns: None if the vlan_range is valid. + :raises: NetworkVlanRangeError if vlan_range is not valid. + """ + for vlan_tag in vlan_range: + if not is_valid_vlan_tag(vlan_tag): + _raise_invalid_tag(str(vlan_tag), vlan_range) + if vlan_range[1] < vlan_range[0]: + raise exceptions.NetworkVlanRangeError( + vlan_range=vlan_range, + error=_("End of VLAN range is less than start of VLAN range")) + + +def parse_network_vlan_range(network_vlan_range): + """Parse a well formed network VLAN range string. + + The network VLAN range string has the format: + network[:vlan_begin:vlan_end] + + :param network_vlan_range: The network VLAN range string to parse. + :returns: A tuple who's 1st element is the network name and 2nd + element is the VLAN range parsed from network_vlan_range. + :raises: NetworkVlanRangeError if network_vlan_range is malformed. + PhysicalNetworkNameError if network_vlan_range is missing a network + name. + """ + entry = network_vlan_range.strip() + if ':' in entry: + if entry.count(':') != 2: + raise exceptions.NetworkVlanRangeError( + vlan_range=entry, + error=_("Need exactly two values for VLAN range")) + network, vlan_min, vlan_max = entry.split(':') + if not network: + raise exceptions.PhysicalNetworkNameError() + + try: + vlan_min = int(vlan_min) + except ValueError: + _raise_invalid_tag(vlan_min, entry) + + try: + vlan_max = int(vlan_max) + except ValueError: + _raise_invalid_tag(vlan_max, entry) + + vlan_range = (vlan_min, vlan_max) + verify_vlan_range(vlan_range) + return network, vlan_range + else: + return entry, None + + +def parse_network_vlan_ranges(network_vlan_ranges_cfg_entries): + """Parse a list of well formed network VLAN range string. + + Behaves like parse_network_vlan_range, but parses a list of + network VLAN strings into an ordered dict. + + :param network_vlan_ranges_cfg_entries: The list of network VLAN + strings to parse. + :returns: An OrderedDict who's keys are network names and values are + the list of VLAN ranges parsed. + :raises: See parse_network_vlan_range. + """ + networks = collections.OrderedDict() + for entry in network_vlan_ranges_cfg_entries: + network, vlan_range = parse_network_vlan_range(entry) + if vlan_range: + networks.setdefault(network, []).append(vlan_range) + else: + networks.setdefault(network, []) + return networks + + +def in_pending_status(status): + """Return True if status is a form of pending""" + return status in (constants.PENDING_CREATE, + constants.PENDING_UPDATE, + constants.PENDING_DELETE) + + +@contextlib.contextmanager +def delete_port_on_error(core_plugin, context, port_id): + """A decorator that deletes a port upon exception. + + This decorator can be used to wrap a block of code that + should delete a port if an exception is raised during the block's + execution. + + :param core_plugin: The core plugin implementing the delete_port method to + call. + :param context: The context. + :param port_id: The port's ID. + :returns: None + """ + try: + yield + except Exception: + with excutils.save_and_reraise_exception(): + try: + core_plugin.delete_port(context, port_id, + l3_port_check=False) + except exceptions.PortNotFound: + LOG.debug("Port %s not found", port_id) + except Exception: + LOG.exception("Failed to delete port: %s", port_id) + + +@contextlib.contextmanager +def update_port_on_error(core_plugin, context, port_id, revert_value): + """A decorator that updates a port upon exception. + + This decorator can be used to wrap a block of code that + should update a port if an exception is raised during the block's + execution. + + :param core_plugin: The core plugin implementing the update_port method to + call. + :param context: The context. + :param port_id: The port's ID. + :param revert_value: The value to revert on the port object. + :returns: None + """ + try: + yield + except Exception: + with excutils.save_and_reraise_exception(): + try: + core_plugin.update_port(context, port_id, + {'port': revert_value}) + except Exception: + LOG.exception("Failed to update port: %s", port_id) + + +def get_interface_name(name, prefix='', max_len=constants.DEVICE_NAME_MAX_LEN): + """Construct an interface name based on the prefix and name. + + The interface name can not exceed the maximum length passed in. Longer + names are hashed to help ensure uniqueness. + """ + requested_name = prefix + name + + if len(requested_name) <= max_len: + return requested_name + + # We can't just truncate because interfaces may be distinguished + # by an ident at the end. A hash over the name should be unique. + # Leave part of the interface name on for easier identification + if (len(prefix) + INTERFACE_HASH_LEN) > max_len: + raise ValueError(_("Too long prefix provided. New name would exceed " + "given length for an interface name.")) + + namelen = max_len - len(prefix) - INTERFACE_HASH_LEN + hashed_name = hashlib.sha1(encodeutils.to_utf8(name)) + new_name = ('%(prefix)s%(truncated)s%(hash)s' % + {'prefix': prefix, 'truncated': name[0:namelen], + 'hash': hashed_name.hexdigest()[0:INTERFACE_HASH_LEN]}) + LOG.info("The requested interface name %(requested_name)s exceeds the " + "%(limit)d character limitation. It was shortened to " + "%(new_name)s to fit.", + {'requested_name': requested_name, + 'limit': max_len, 'new_name': new_name}) + return new_name diff --git a/neutron_lib/tests/unit/plugins/test_utils.py b/neutron_lib/tests/unit/plugins/test_utils.py new file mode 100644 index 000000000..76512f6fa --- /dev/null +++ b/neutron_lib/tests/unit/plugins/test_utils.py @@ -0,0 +1,209 @@ +# Copyright (c) 2015 IBM Corp. +# +# 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. + +import hashlib + +import mock +from oslo_utils import excutils + +from neutron_lib import constants +from neutron_lib import exceptions +from neutron_lib.plugins import utils +from neutron_lib.tests import _base as base + + +LONG_NAME1 = "A_REALLY_LONG_INTERFACE_NAME1" +LONG_NAME2 = "A_REALLY_LONG_INTERFACE_NAME2" +SHORT_NAME = "SHORT" +MOCKED_HASH = "mockedhash" + + +class MockSHA(object): + def hexdigest(self): + return MOCKED_HASH + + +class TestUtils(base.BaseTestCase): + + def test_is_valid_vlan_tag(self): + for v in [constants.MIN_VLAN_TAG, constants.MIN_VLAN_TAG + 2, + constants.MAX_VLAN_TAG, constants.MAX_VLAN_TAG - 2]: + self.assertTrue(utils.is_valid_vlan_tag(v)) + + def test_is_valid_vlan_tag_invalid_data(self): + for v in [constants.MIN_VLAN_TAG - 1, constants.MIN_VLAN_TAG - 2, + constants.MAX_VLAN_TAG + 1, constants.MAX_VLAN_TAG + 2]: + self.assertFalse(utils.is_valid_vlan_tag(v)) + + def test_verify_vlan_range(self): + for v in [(constants.MIN_VLAN_TAG, constants.MIN_VLAN_TAG + 2), + (constants.MIN_VLAN_TAG + 2, constants.MAX_VLAN_TAG - 2)]: + self.assertIsNone(utils.verify_vlan_range(v)) + + def test_verify_vlan_range_invalid_range(self): + for v in [(constants.MIN_VLAN_TAG, constants.MAX_VLAN_TAG + 2), + (constants.MIN_VLAN_TAG + 4, constants.MIN_VLAN_TAG + 1)]: + self.assertRaises(exceptions.NetworkVlanRangeError, + utils.verify_vlan_range, v) + + def test_parse_network_vlan_range(self): + self.assertEqual( + ('n1', (1, 3)), + utils.parse_network_vlan_range('n1:1:3')) + self.assertEqual( + ('n1', (1, 1111)), + utils.parse_network_vlan_range('n1:1:1111')) + + def test_parse_network_vlan_range_invalid_range(self): + self.assertRaises(exceptions.NetworkVlanRangeError, + utils.parse_network_vlan_range, + 'n1:1,4') + + def test_parse_network_vlan_range_missing_network(self): + self.assertRaises(exceptions.PhysicalNetworkNameError, + utils.parse_network_vlan_range, + ':1:4') + + def test_parse_network_vlan_range_invalid_min_type(self): + self.assertRaises(exceptions.NetworkVlanRangeError, + utils.parse_network_vlan_range, + 'n1:a:4') + + def test_parse_network_vlan_ranges(self): + ranges = utils.parse_network_vlan_ranges(['n1:1:3', 'n2:2:4']) + self.assertEqual(2, len(ranges.keys())) + self.assertIn('n1', ranges.keys()) + self.assertIn('n2', ranges.keys()) + self.assertEqual(2, len(ranges['n1'][0])) + self.assertEqual(1, ranges['n1'][0][0]) + self.assertEqual(3, ranges['n1'][0][1]) + self.assertEqual(2, len(ranges['n2'][0])) + self.assertEqual(2, ranges['n2'][0][0]) + self.assertEqual(4, ranges['n2'][0][1]) + + def test_is_valid_gre_id(self): + for v in [constants.MIN_GRE_ID, constants.MIN_GRE_ID + 2, + constants.MAX_GRE_ID, constants.MAX_GRE_ID - 2]: + self.assertTrue(utils.is_valid_gre_id(v)) + + def test_is_valid_gre_id_invalid_id(self): + for v in [constants.MIN_GRE_ID - 1, constants.MIN_GRE_ID - 2, + True, 'z', 99.999, []]: + self.assertFalse(utils.is_valid_gre_id(v)) + + def test_is_valid_vxlan_vni(self): + for v in [constants.MIN_VXLAN_VNI, constants.MAX_VXLAN_VNI, + constants.MIN_VXLAN_VNI + 1, constants.MAX_VXLAN_VNI - 1]: + self.assertTrue(utils.is_valid_vxlan_vni(v)) + + def test_is_valid_vxlan_vni_invalid_values(self): + for v in [constants.MIN_VXLAN_VNI - 1, constants.MAX_VXLAN_VNI + 1, + True, 'a', False, {}]: + self.assertFalse(utils.is_valid_vxlan_vni(v)) + + def test_is_valid_geneve_vni(self): + for v in [constants.MIN_GENEVE_VNI, constants.MAX_GENEVE_VNI, + constants.MIN_GENEVE_VNI + 1, constants.MAX_GENEVE_VNI - 1]: + self.assertTrue(utils.is_valid_geneve_vni(v)) + + def test_is_valid_geneve_vni_invalid_values(self): + for v in [constants.MIN_GENEVE_VNI - 1, constants.MAX_GENEVE_VNI + 1, + True, False, (), 'True']: + self.assertFalse(utils.is_valid_geneve_vni(v)) + + def test_verify_tunnel_range_known_tunnel_type(self): + mock_fns = [mock.Mock(return_value=False) for _ in range(3)] + mock_map = { + constants.TYPE_GRE: mock_fns[0], + constants.TYPE_VXLAN: mock_fns[1], + constants.TYPE_GENEVE: mock_fns[2] + } + + with mock.patch.dict(utils._TUNNEL_MAPPINGS, mock_map): + for t in [constants.TYPE_GRE, constants.TYPE_VXLAN, + constants.TYPE_GENEVE]: + self.assertRaises( + exceptions.NetworkTunnelRangeError, + utils.verify_tunnel_range, [0, 1], t) + for f in mock_fns: + f.assert_called_once_with(0) + + def test_verify_tunnel_range_invalid_range(self): + for r in [[1, 0], [0, -1], [2, 1]]: + self.assertRaises( + exceptions.NetworkTunnelRangeError, + utils.verify_tunnel_range, r, constants.TYPE_FLAT) + + def test_verify_tunnel_range(self): + for r in [[0, 1], [-1, 0], [1, 2]]: + self.assertIsNone( + utils.verify_tunnel_range(r, constants.TYPE_FLAT)) + + def test_delete_port_on_error(self): + core_plugin = mock.Mock() + with mock.patch.object(excutils, 'save_and_reraise_exception'): + with mock.patch.object(utils, 'LOG'): + with utils.delete_port_on_error(core_plugin, 'ctx', '1'): + raise Exception() + + core_plugin.delete_port.assert_called_once_with( + 'ctx', '1', l3_port_check=False) + + def test_update_port_on_error(self): + core_plugin = mock.Mock() + with mock.patch.object(excutils, 'save_and_reraise_exception'): + with mock.patch.object(utils, 'LOG'): + with utils.update_port_on_error(core_plugin, 'ctx', '1', '2'): + raise Exception() + + core_plugin.update_port.assert_called_once_with( + 'ctx', '1', {'port': '2'}) + + @mock.patch.object(hashlib, 'sha1', return_value=MockSHA()) + def test_get_interface_name(self, mock_sha1): + prefix = "pre-" + prefix_long = "long_prefix" + prefix_exceeds_max_dev_len = "much_too_long_prefix" + hash_used = MOCKED_HASH[0:6] + + self.assertEqual("A_REALLY_" + hash_used, + utils.get_interface_name(LONG_NAME1)) + self.assertEqual("SHORT", + utils.get_interface_name(SHORT_NAME)) + self.assertEqual("pre-A_REA" + hash_used, + utils.get_interface_name(LONG_NAME1, prefix=prefix)) + self.assertEqual("pre-SHORT", + utils.get_interface_name(SHORT_NAME, prefix=prefix)) + # len(prefix) > max_device_len - len(hash_used) + self.assertRaises(ValueError, utils.get_interface_name, SHORT_NAME, + prefix_long) + # len(prefix) > max_device_len + self.assertRaises(ValueError, utils.get_interface_name, SHORT_NAME, + prefix=prefix_exceeds_max_dev_len) + + def test_get_interface_uniqueness(self): + prefix = "prefix-" + if_prefix1 = utils.get_interface_name(LONG_NAME1, prefix=prefix) + if_prefix2 = utils.get_interface_name(LONG_NAME2, prefix=prefix) + self.assertNotEqual(if_prefix1, if_prefix2) + + @mock.patch.object(hashlib, 'sha1', return_value=MockSHA()) + def test_get_interface_max_len(self, mock_sha1): + self.assertEqual(constants.DEVICE_NAME_MAX_LEN, + len(utils.get_interface_name(LONG_NAME1))) + self.assertEqual(10, len(utils.get_interface_name(LONG_NAME1, + max_len=10))) + self.assertEqual(12, len(utils.get_interface_name(LONG_NAME1, + prefix="pre-", + max_len=12))) diff --git a/releasenotes/notes/rehome-plugin-utils-39e3f839c0538de9.yaml b/releasenotes/notes/rehome-plugin-utils-39e3f839c0538de9.yaml new file mode 100644 index 000000000..6f94867d3 --- /dev/null +++ b/releasenotes/notes/rehome-plugin-utils-39e3f839c0538de9.yaml @@ -0,0 +1,6 @@ +--- +features: + - The publically consumed API's from ``neutron.plugins.common.utils`` are + now available in ``neutron_lib.plugins.utils``. + - The ``NetworkVlanRangeError`` and ``PhysicalNetworkNameError`` exception + classes are now available in ``neutron_lib.exceptions``.