diff --git a/setup.cfg b/setup.cfg index 7a9de0c..4b19c3a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,14 +14,17 @@ classifier = License :: OSI Approved :: Apache Software License Operating System :: OS Independent Programming Language :: Python - Programming Language :: Python :: 2 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 [files] packages = stackhpc_inspector_plugins [entry_points] -ironic_inspector.hooks.processing = +ironic.inspection.hooks = ib_physnet = stackhpc_inspector_plugins.plugins.ib_physnet:IBPhysnetHook - system_name_physnet = stackhpc_inspector_plugins.plugins.system_name_physnet:SystemNamePhysnetHook - system_name_llc = stackhpc_inspector_plugins.plugins.system_name_llc:SystemNameLocalLinkConnectionHook + system_name_physnet = stackhpc_inspector_plugins.plugins.ib_physnet:SystemNamePhysnetHook diff --git a/stackhpc_inspector_plugins/conf.py b/stackhpc_inspector_plugins/conf.py index f4c0ec7..9b3d4d9 100644 --- a/stackhpc_inspector_plugins/conf.py +++ b/stackhpc_inspector_plugins/conf.py @@ -15,22 +15,20 @@ from oslo_config import cfg -from ironic_inspector.common.i18n import _ - PORT_PHYSNET_OPTS = [ cfg.StrOpt( 'ib_physnet', - help=_('Name of the physical network that the Infiniband network is ' - 'on')), + help=('Name of the physical network that the Infiniband network is ' + 'on')), cfg.ListOpt( 'switch_sys_name_mapping', default=[], - help=_('Comma-separated list of ' - ': tuples mapping switch ' - 'system names received via LLDP to a physical network to apply ' - 'to ports that are connected to a switch with a matching ' - 'system name.')), + help=('Comma-separated list of ' + ': tuples mapping switch ' + 'system names received via LLDP to a physical network to apply ' + 'to ports that are connected to a switch with a matching ' + 'system name.')), ] diff --git a/stackhpc_inspector_plugins/plugins/ib_physnet.py b/stackhpc_inspector_plugins/plugins/ib_physnet.py index 10716cb..ac970ff 100644 --- a/stackhpc_inspector_plugins/plugins/ib_physnet.py +++ b/stackhpc_inspector_plugins/plugins/ib_physnet.py @@ -1,5 +1,3 @@ -# Copyright (c) 2018 StackHPC Ltd. -# # 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 @@ -13,25 +11,27 @@ # See the License for the specific language governing permissions and # limitations under the License. -from ironic_inspector import utils from oslo_config import cfg +from oslo_log import log as logging -from ironic_inspector.plugins import base_physnet - -LOG = utils.getProcessingLogger(__name__) +from ironic.drivers.modules.inspector.hooks import base +from ironic.drivers.modules.inspector import lldp_parsers +from ironic import objects CONF = cfg.CONF +LOG = logging.getLogger(__name__) -class IBPhysnetHook(base_physnet.BasePhysnetHook): - """Inspector hook to assign ports a physical network for IB interfaces. +class IBPhysnetHook(base.InspectionHook): + """Hook to set the port's physical_network field. - This plugin sets the physical network for ports that are determined to be - Infiniband ports. The physical network is given by the configuration - option [port_physnet] ib_physnet. + Set the ironic port's physical_network field based on a CIDR to physical + network mapping in the configuration. """ - def get_physnet(self, port, iface_name, introspection_data): + dependencies = ['validate-interfaces'] + + def get_physical_network(self, interface, plugin_data): """Return a physical network to apply to a port. :param port: The ironic port to patch. @@ -39,8 +39,130 @@ def get_physnet(self, port, iface_name, introspection_data): :param introspection_data: Introspection data. :returns: The physical network to set, or None. """ - proc_data = introspection_data['all_interfaces'][iface_name] + iface_name = interface['name'] + proc_data = plugin_data['all_interfaces'][iface_name] if proc_data.get('client_id'): LOG.debug("Interface %s is an Infiniband port, physnet %s", iface_name, CONF.port_physnet.ib_physnet) return CONF.port_physnet.ib_physnet + + def __call__(self, task, inventory, plugin_data): + """Process inspection data and patch the port's physical network.""" + + node_ports = objects.Port.list_by_node_id(task.context, task.node.id) + ports_dict = {p.address: p for p in node_ports} + + for interface in inventory['interfaces']: + if interface['name'] not in plugin_data['all_interfaces']: + LOG.debug("No processed data for interface %s on node %s, " + "skipping physical network processing.", + interface['name'], task.node.uuid) + continue + + mac_address = interface['mac_address'] + port = ports_dict.get(mac_address) + if not port: + LOG.debug("Skipping physical network processing for interface " + "%s on node %s - matching port not found in Ironic.", + mac_address, task.node.uuid) + continue + + # Determine the physical network for this port, using the interface + # IPs and CIDR map configuration. + phys_network = self.get_physical_network(interface, plugin_data) + if phys_network is None: + LOG.debug("Skipping physical network processing for interface " + "%s on node %s - no physical network mapping.", + mac_address, task.node.uuid) + continue + + if getattr(port, 'physical_network', '') != phys_network: + port.physical_network = phys_network + port.save() + LOG.info('Updated physical_network of port %s to %s', + port.uuid, port.physical_network) + + +def parse_mappings(mapping_list): + """Parse a list of mapping strings into a dictionary. + + Adapted from neutron_lib.utils.helpers.parse_mappings. + + :param mapping_list: A list of strings of the form ':'. + :returns: A dict mapping keys to values or to list of values. + :raises ValueError: Upon malformed data or duplicate keys. + """ + mappings = {} + for mapping in mapping_list: + mapping = mapping.strip() + if not mapping: + continue + split_result = mapping.split(':') + if len(split_result) != 2: + raise ValueError("Invalid mapping: '%s'" % mapping) + key = split_result[0].strip() + if not key: + raise ValueError("Missing key in mapping: '%s'" % mapping) + value = split_result[1].strip() + if not value: + raise ValueError("Missing value in mapping: '%s'" % mapping) + if key in mappings: + raise ValueError("Key %(key)s in mapping: '%(mapping)s' not " + "unique" % {'key': key, 'mapping': mapping}) + mappings[key] = value + return mappings + + +class SystemNamePhysnetHook(IBPhysnetHook): + """Inspector hook to assign ports a physical network based on switch name. + + This plugin uses the configuration option [port_physnet] + switch_sys_name_mapping to map switch names to a physical network. If a + port has received LLDP data with a switch system name in the mapping, the + corresponding physical network will be applied to the port. + """ + + def _get_switch_sys_name_mapping(self): + """Return a dict mapping switch system names to physical networks.""" + if not hasattr(self, '_switch_sys_name_mapping'): + self._switch_sys_name_mapping = parse_mappings( + CONF.port_physnet.switch_sys_name_mapping) + return self._switch_sys_name_mapping + + def get_physical_network(self, interface, plugin_data): + """Return a physical network to apply to a port. + + :param port: The ironic port to patch. + :param iface_name: Name of the interface. + :param introspection_data: Introspection data. + :returns: The physical network to set, or None. + """ + # Check if LLDP data was already processed + if 'parsed_lldp' not in plugin_data: + LOG.error("No LLDP data, parse_lldp hook is required. ") + return + + # check we have data for this interface + iface_name = interface['name'] + lldp_proc = plugin_data['parsed_lldp'].get(iface_name) + if not lldp_proc: + LOG.debug("No LLDP data for interface %s", iface_name) + return + + # Switch system name mapping. + switch_sys_name = lldp_proc.get(lldp_parsers.LLDP_SYS_NAME_NM) + if not switch_sys_name: + LOG.debug("No switch system name in LLDP data for interface %s", + iface_name) + return + + mapping = self._get_switch_sys_name_mapping() + if switch_sys_name not in mapping: + LOG.debug("No config set for switch system name %s for " + "interface %s", switch_sys_name, iface_name) + return + + LOG.debug("Interface %s connected to switch with system name " + "%s, physnet %s", iface_name, switch_sys_name, + mapping[switch_sys_name]) + return mapping[switch_sys_name] diff --git a/stackhpc_inspector_plugins/plugins/system_name_llc.py b/stackhpc_inspector_plugins/plugins/system_name_llc.py deleted file mode 100644 index 4acf417..0000000 --- a/stackhpc_inspector_plugins/plugins/system_name_llc.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright (c) 2017 StackHPC Ltd. -# -# 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. - -"""System name LLDP Processing Hook""" - -import binascii - -from ironic_inspector.common import lldp_parsers -from ironic_inspector.common import lldp_tlvs as tlv -from ironic_inspector.plugins import base -from ironic_inspector import utils -from oslo_config import cfg - -LOG = utils.getProcessingLogger(__name__) - -CONF = cfg.CONF - -SYSTEM_NAME_ITEM_NAME = "switch_info" - -LLDP_PROC_DATA_MAPPING = {lldp_parsers.LLDP_SYS_NAME_NM: SYSTEM_NAME_ITEM_NAME} - - -class SystemNameLocalLinkConnectionHook(base.ProcessingHook): - """Process the system name LLDP packet field and set as switch_info. - - Some Neutron drivers expect the switch_info field in a port's - local_link_connection attribute to contain the system name of a switch. - This plugin will store the system name received via LLDP (if present) in - the switch_info field of the Ironic port's local_link_connection attribute. - - It should be noted that some Neutron mechanism drivers expect switch_info - to contain something other than the system name, in which case this plugin - should not be used. - """ - - def _get_local_link_patch(self, tlv_type, tlv_value, port, node_info): - try: - data = bytearray(binascii.unhexlify(tlv_value)) - except TypeError: - LOG.warning("TLV value for TLV type %d not in correct" - "format, ensure TLV value is in " - "hexidecimal format when sent to " - "inspector", tlv_type, node_info=node_info) - return - - item = value = None - if tlv_type == tlv.LLDP_TLV_SYS_NAME: - try: - sys_name = tlv.SysName.parse(data) - except UnicodeDecodeError as e: - LOG.warning("TLV parse error for System Name: %s", e, - node_info=node_info) - return - - item = SYSTEM_NAME_ITEM_NAME - value = sys_name.value - - if item and value: - if (not CONF.processing.overwrite_existing and - item in port.local_link_connection): - return - return {'op': 'add', - 'path': '/local_link_connection/%s' % item, - 'value': value} - - def _get_lldp_processed_patch(self, name, item, lldp_proc_data, port): - - if 'lldp_processed' not in lldp_proc_data: - return - - value = lldp_proc_data['lldp_processed'].get(name) - - if value: - if (not CONF.processing.overwrite_existing and - item in port.local_link_connection): - return - return {'op': 'add', - 'path': '/local_link_connection/%s' % item, - 'value': value} - - def before_update(self, introspection_data, node_info, **kwargs): - """Process LLDP data and patch Ironic port local link connection""" - inventory = utils.get_inventory(introspection_data) - - ironic_ports = node_info.ports() - - for iface in inventory['interfaces']: - if iface['name'] not in introspection_data['all_interfaces']: - continue - - mac_address = iface['mac_address'] - port = ironic_ports.get(mac_address) - if not port: - LOG.debug("Skipping LLC processing for interface %s, matching " - "port not found in Ironic.", mac_address, - node_info=node_info, data=introspection_data) - continue - - lldp_data = iface.get('lldp') - if lldp_data is None: - LOG.warning("No LLDP Data found for interface %s", - mac_address, node_info=node_info, - data=introspection_data) - continue - - patches = [] - # First check if lldp data was already processed by lldp_basic - # plugin which stores data in 'all_interfaces' - proc_data = introspection_data['all_interfaces'][iface['name']] - - for name, item in LLDP_PROC_DATA_MAPPING.items(): - patch = self._get_lldp_processed_patch(name, item, - proc_data, port) - if patch is not None: - patches.append(patch) - - # If no processed lldp data was available then parse raw lldp data - if not patches: - for tlv_type, tlv_value in lldp_data: - patch = self._get_local_link_patch(tlv_type, tlv_value, - port, node_info) - if patch is not None: - patches.append(patch) - - node_info.patch_port(port, patches) diff --git a/stackhpc_inspector_plugins/plugins/system_name_physnet.py b/stackhpc_inspector_plugins/plugins/system_name_physnet.py deleted file mode 100644 index 0e098cb..0000000 --- a/stackhpc_inspector_plugins/plugins/system_name_physnet.py +++ /dev/null @@ -1,65 +0,0 @@ -# Copyright (c) 2017 StackHPC Ltd. -# -# 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. - -from ironic_inspector.common import lldp_parsers -from ironic_inspector import utils -from oslo_config import cfg - -from ironic_inspector.plugins import base_physnet -from stackhpc_inspector_plugins import utils as sip_utils - -LOG = utils.getProcessingLogger(__name__) - -CONF = cfg.CONF - - -class SystemNamePhysnetHook(base_physnet.BasePhysnetHook): - """Inspector hook to assign ports a physical network based on switch name. - - This plugin uses the configuration option [port_physnet] - switch_sys_name_mapping to map switch names to a physical network. If a - port has received LLDP data with a switch system name in the mapping, the - corresponding physical network will be applied to the port. - """ - - def _get_switch_sys_name_mapping(self): - """Return a dict mapping switch system names to physical networks.""" - if not hasattr(self, '_switch_sys_name_mapping'): - self._switch_sys_name_mapping = sip_utils.parse_mappings( - CONF.port_physnet.switch_sys_name_mapping) - return self._switch_sys_name_mapping - - def get_physnet(self, port, iface_name, introspection_data): - """Return a physical network to apply to a port. - - :param port: The ironic port to patch. - :param iface_name: Name of the interface. - :param introspection_data: Introspection data. - :returns: The physical network to set, or None. - """ - # Check if LLDP data was already processed by lldp_basic plugin - # which stores data in 'all_interfaces' - proc_data = introspection_data['all_interfaces'][iface_name] - if 'lldp_processed' not in proc_data: - return - - lldp_proc = proc_data['lldp_processed'] - - # Switch system name mapping. - switch_sys_name = lldp_proc.get(lldp_parsers.LLDP_SYS_NAME_NM) - if switch_sys_name: - mapping = self._get_switch_sys_name_mapping() - if switch_sys_name in mapping: - return mapping[switch_sys_name] diff --git a/stackhpc_inspector_plugins/tests/unit/__init__.py b/stackhpc_inspector_plugins/tests/unit/__init__.py index e69de29..b3b23b5 100644 --- a/stackhpc_inspector_plugins/tests/unit/__init__.py +++ b/stackhpc_inspector_plugins/tests/unit/__init__.py @@ -0,0 +1,3 @@ +# NOTE(TheJulia): This is to force oslo_service from trying to use eventlet. +from oslo_service import backend +backend.init_backend(backend.BackendType.THREADING) diff --git a/stackhpc_inspector_plugins/tests/unit/test_plugins_ib_physnet.py b/stackhpc_inspector_plugins/tests/unit/test_plugins_ib_physnet.py index 00b4a76..2c587a7 100644 --- a/stackhpc_inspector_plugins/tests/unit/test_plugins_ib_physnet.py +++ b/stackhpc_inspector_plugins/tests/unit/test_plugins_ib_physnet.py @@ -13,63 +13,180 @@ # See the License for the specific language governing permissions and # limitations under the License. -import mock +from unittest import mock -from ironic_inspector import node_cache -from ironic_inspector.test import base as test_base -from oslo_config import cfg +from ironic.conductor import task_manager +from ironic import objects +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as obj_utils +from ironic.conf import CONF +import testtools from stackhpc_inspector_plugins.plugins import ib_physnet -class TestIBPhysnetHook(test_base.NodeTest): +_INTERFACE_1 = { + 'name': 'em0', + 'mac_address': '11:11:11:11:11:11', + 'ipv4_address': '192.168.10.1', + 'ipv6_address': '2001:db8::1', +} +_INTERFACE_2 = { + 'name': 'em1', + 'mac_address': '22:22:22:22:22:22', + 'ipv4_address': '192.168.12.2', + 'ipv6_address': 'fe80:5054::', + 'client_id': ('ff:00:00:00:00:00:02:00:00:02:c9:00:7c:fe:' + '90:03:00:3a:4b:0a'), +} + +_INTERFACE_3 = { + 'name': 'em2', + 'mac_address': '33:33:33:33:33:33', + 'ipv4_address': '192.168.12.3', + 'ipv6_address': 'fe80::5054:ff:fea7:87:6482', +} + +_INVENTORY = { + 'interfaces': [_INTERFACE_1, _INTERFACE_2, _INTERFACE_3] +} + +_PLUGIN_DATA = { + 'all_interfaces': {'em0': _INTERFACE_1, 'em1': _INTERFACE_2}, + 'parsed_lldp': {'em1': {'switch_system_name': 'switch-1'}}, +} + + +class TestIBPhysnetHook(db_base.DbTestCase): def setUp(self): - super(TestIBPhysnetHook, self).setUp() - self.hook = ib_physnet.IBPhysnetHook() - self.data = { - 'inventory': { - 'interfaces': [{ - 'name': 'em1', 'mac_address': '11:11:11:11:11:11', - 'ipv4_address': '1.1.1.1', - }], - 'cpu': 1, - 'disks': 1, - 'memory': 1 - }, - 'all_interfaces': { - 'em1': { - 'client_id': ('ff:00:00:00:00:00:02:00:00:02:c9:00:7c:fe:' - '90:03:00:3a:4b:0a'), - }, - } - } - - ports = [mock.Mock(spec=['address', 'uuid', 'physical_network'], - address=a, physical_network='physnet1') - for a in ('11:11:11:11:11:11',)] - self.node_info = node_cache.NodeInfo(uuid=self.uuid, started_at=0, - node=self.node, ports=ports) - - def test_expected_data_ib(self): - cfg.CONF.set_override('ib_physnet', 'physnet1', - group='port_physnet') - port = list(self.node_info.ports().values())[0] - physnet = self.hook.get_physnet(port, 'em1', self.data) - self.assertEqual(physnet, 'physnet1') - - def test_expected_data_client_id_is_none(self): - cfg.CONF.set_override('ib_physnet', 'physnet1', - group='port_physnet') - self.data['all_interfaces']['em1']['client_id'] = None - port = list(self.node_info.ports().values())[0] - physnet = self.hook.get_physnet(port, 'em1', self.data) - self.assertIsNone(physnet) - - def test_expected_data_no_client_id(self): - cfg.CONF.set_override('ib_physnet', 'physnet1', - group='port_physnet') - del self.data['all_interfaces']['em1']['client_id'] - port = list(self.node_info.ports().values())[0] - physnet = self.hook.get_physnet(port, 'em1', self.data) - self.assertIsNone(physnet) + super().setUp() + CONF.set_override('enabled_inspect_interfaces', + ['agent', 'no-inspect']) + self.node = obj_utils.create_test_node(self.context, + inspect_interface='agent') + self.inventory = _INVENTORY + self.plugin_data = _PLUGIN_DATA + + @mock.patch.object(objects.Port, 'list_by_node_id', autospec=True) + def test_physical_network(self, mock_list_by_nodeid): + CONF.set_override('ib_physnet', 'ibphysnet', + group='port_physnet') + with task_manager.acquire(self.context, self.node.id) as task: + port1 = obj_utils.create_test_port(self.context, + address='11:11:11:11:11:11', + node_id=self.node.id) + port2 = obj_utils.create_test_port( + self.context, id=988, + uuid='2be26c0b-03f2-4d2e-ae87-c02d7f33c781', + address='22:22:22:22:22:22', node_id=self.node.id) + ports = [port1, port2] + + mock_list_by_nodeid.return_value = ports + + ib_physnet.IBPhysnetHook().__call__( + task, self.inventory, self.plugin_data) + + port1.refresh() + port2.refresh() + self.assertEqual(port2.physical_network, 'ibphysnet') + self.assertIsNone(port1.physical_network) + + +class TestSystemNamePhysnetHook(db_base.DbTestCase): + def setUp(self): + super().setUp() + CONF.set_override('enabled_inspect_interfaces', + ['agent', 'no-inspect']) + self.node = obj_utils.create_test_node(self.context, + inspect_interface='agent') + self.inventory = _INVENTORY + self.plugin_data = _PLUGIN_DATA + + @mock.patch.object(objects.Port, 'list_by_node_id', autospec=True) + def test_sys_name_success(self, mock_list_by_nodeid): + sys_name_mapping = 'switch-1:ibphysnet,switch-2:physnet2' + CONF.set_override('switch_sys_name_mapping', sys_name_mapping, + group='port_physnet') + with task_manager.acquire(self.context, self.node.id) as task: + port1 = obj_utils.create_test_port(self.context, + address='11:11:11:11:11:11', + node_id=self.node.id) + port2 = obj_utils.create_test_port( + self.context, id=988, + uuid='2be26c0b-03f2-4d2e-ae87-c02d7f33c781', + address='22:22:22:22:22:22', node_id=self.node.id) + ports = [port1, port2] + + mock_list_by_nodeid.return_value = ports + + ib_physnet.SystemNamePhysnetHook().__call__( + task, self.inventory, self.plugin_data) + + port1.refresh() + port2.refresh() + self.assertEqual(port2.physical_network, 'ibphysnet') + self.assertIsNone(port1.physical_network) + + @mock.patch.object(objects.Port, 'list_by_node_id', autospec=True) + def test_sys_name_success_no_data(self, mock_list_by_nodeid): + sys_name_mapping = 'switch-1:ibphysnet,switch-2:physnet2' + CONF.set_override('switch_sys_name_mapping', sys_name_mapping, + group='port_physnet') + del _PLUGIN_DATA['parsed_lldp'] + with task_manager.acquire(self.context, self.node.id) as task: + port1 = obj_utils.create_test_port(self.context, + address='11:11:11:11:11:11', + node_id=self.node.id) + port2 = obj_utils.create_test_port( + self.context, id=988, + uuid='2be26c0b-03f2-4d2e-ae87-c02d7f33c781', + address='22:22:22:22:22:22', node_id=self.node.id) + ports = [port1, port2] + + mock_list_by_nodeid.return_value = ports + + ib_physnet.SystemNamePhysnetHook().__call__( + task, self.inventory, self.plugin_data) + + port1.refresh() + port2.refresh() + self.assertIsNone(port2.physical_network) + self.assertIsNone(port1.physical_network) + + def parse(self, mapping_list): + return ib_physnet.parse_mappings(mapping_list) + + def test_parse_mappings_fails_for_missing_separator(self): + with testtools.ExpectedException(ValueError): + self.parse(['key']) + + def test_parse_mappings_fails_for_missing_key(self): + with testtools.ExpectedException(ValueError): + self.parse([':val']) + + def test_parse_mappings_fails_for_missing_value(self): + with testtools.ExpectedException(ValueError): + self.parse(['key:']) + + def test_parse_mappings_fails_for_extra_separator(self): + with testtools.ExpectedException(ValueError): + self.parse(['key:val:junk']) + + def test_parse_mappings_fails_for_duplicate_key(self): + with testtools.ExpectedException(ValueError): + self.parse(['key:val1', 'key:val2']) + + def test_parse_mappings_succeeds_for_one_mapping(self): + self.assertEqual({'key': 'val'}, self.parse(['key:val'])) + + def test_parse_mappings_succeeds_for_n_mappings(self): + self.assertEqual({'key1': 'val1', 'key2': 'val2'}, + self.parse(['key1:val1', 'key2:val2'])) + + def test_parse_mappings_succeeds_for_duplicate_value(self): + self.assertEqual({'key1': 'val', 'key2': 'val'}, + self.parse(['key1:val', 'key2:val'])) + + def test_parse_mappings_succeeds_for_no_mappings(self): + self.assertEqual({}, self.parse([''])) diff --git a/stackhpc_inspector_plugins/tests/unit/test_plugins_system_name_llc.py b/stackhpc_inspector_plugins/tests/unit/test_plugins_system_name_llc.py deleted file mode 100644 index 74e6718..0000000 --- a/stackhpc_inspector_plugins/tests/unit/test_plugins_system_name_llc.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (c) 2017 StackHPC Ltd. -# -# 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 mock - -from ironic_inspector import node_cache -from ironic_inspector.test import base as test_base -from ironic_inspector import utils -from oslo_config import cfg - -from stackhpc_inspector_plugins.plugins import system_name_llc - - -class TestSystemNameLocalLinkConnectionHook(test_base.NodeTest): - hook = system_name_llc.SystemNameLocalLinkConnectionHook() - - def setUp(self): - super(TestSystemNameLocalLinkConnectionHook, self).setUp() - self.data = { - 'inventory': { - 'interfaces': [{ - 'name': 'em1', 'mac_address': '11:11:11:11:11:11', - 'ipv4_address': '1.1.1.1', - 'lldp': [ - (0, ''), - (5, '7377697463682d31') # switch-1 - ] - }], - 'cpu': 1, - 'disks': 1, - 'memory': 1 - }, - 'all_interfaces': { - 'em1': {}, - } - } - - llc = { - 'switch_info': 'switch-2' - } - - ports = [mock.Mock(spec=['address', 'uuid', 'local_link_connection'], - address=a, local_link_connection=llc) - for a in ('11:11:11:11:11:11',)] - self.node_info = node_cache.NodeInfo(uuid=self.uuid, started_at=0, - node=self.node, ports=ports) - - @mock.patch.object(node_cache.NodeInfo, 'patch_port') - def test_expected_data(self, mock_patch): - patches = [ - {'path': '/local_link_connection/switch_info', - 'value': 'switch-1', 'op': 'add'}, - ] - self.hook.before_update(self.data, self.node_info) - self.assertCalledWithPatch(patches, mock_patch) - - @mock.patch.object(node_cache.NodeInfo, 'patch_port') - def test_invalid_system_name_subtype(self, mock_patch): - # The system name is a UTF-8 encoded string. - self.data['inventory']['interfaces'][0]['lldp'][1] = (5, 'c328') - patches = [] - self.hook.before_update(self.data, self.node_info) - self.assertCalledWithPatch(patches, mock_patch) - - @mock.patch.object(node_cache.NodeInfo, 'patch_port') - def test_lldp_none(self, mock_patch): - self.data['inventory']['interfaces'][0]['lldp'] = None - patches = [] - self.hook.before_update(self.data, self.node_info) - self.assertCalledWithPatch(patches, mock_patch) - - @mock.patch.object(node_cache.NodeInfo, 'patch_port') - def test_interface_not_in_all_interfaces(self, mock_patch): - self.data['all_interfaces'] = {} - patches = [] - self.hook.before_update(self.data, self.node_info) - self.assertCalledWithPatch(patches, mock_patch) - - @mock.patch.object(node_cache.NodeInfo, 'patch_port') - def test_interface_not_in_ironic(self, mock_patch): - self.node_info._ports = {} - patches = [] - self.hook.before_update(self.data, self.node_info) - self.assertCalledWithPatch(patches, mock_patch) - - def test_no_inventory(self): - del self.data['inventory'] - self.assertRaises(utils.Error, self.hook.before_update, - self.data, self.node_info) - - @mock.patch.object(node_cache.NodeInfo, 'patch_port') - def test_no_overwrite(self, mock_patch): - cfg.CONF.set_override('overwrite_existing', False, group='processing') - patches = [] - self.hook.before_update(self.data, self.node_info) - self.assertCalledWithPatch(patches, mock_patch) - - @mock.patch.object(node_cache.NodeInfo, 'patch_port') - def test_processed_data_available(self, mock_patch): - self.data['all_interfaces'] = { - 'em1': { - "ip": self.ips[0], "mac": self.macs[0], - "lldp_processed": { - "switch_system_name": "switch-1", - } - } - } - - patches = [ - {'path': '/local_link_connection/switch_info', - 'value': 'switch-1', 'op': 'add'}, - ] - self.hook.before_update(self.data, self.node_info) - self.assertCalledWithPatch(patches, mock_patch) diff --git a/stackhpc_inspector_plugins/tests/unit/test_plugins_system_name_physnet.py b/stackhpc_inspector_plugins/tests/unit/test_plugins_system_name_physnet.py deleted file mode 100644 index 0461cfd..0000000 --- a/stackhpc_inspector_plugins/tests/unit/test_plugins_system_name_physnet.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (c) 2017 StackHPC Ltd. -# -# 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 mock - -from ironic_inspector import node_cache -from ironic_inspector.test import base as test_base -from oslo_config import cfg - -from stackhpc_inspector_plugins.plugins import system_name_physnet - - -class TestSystemNamePhysnetHook(test_base.NodeTest): - - def setUp(self): - super(TestSystemNamePhysnetHook, self).setUp() - self.hook = system_name_physnet.SystemNamePhysnetHook() - self.data = { - 'inventory': { - 'interfaces': [{ - 'name': 'em1', 'mac_address': '11:11:11:11:11:11', - 'ipv4_address': '1.1.1.1', - }], - 'cpu': 1, - 'disks': 1, - 'memory': 1 - }, - 'all_interfaces': { - 'em1': { - 'lldp_processed': { - 'switch_system_name': 'switch-1', - } - } - } - } - - ports = [mock.Mock(spec=['address', 'uuid', 'physical_network'], - address=a, physical_network='physnet1') - for a in ('11:11:11:11:11:11',)] - self.node_info = node_cache.NodeInfo(uuid=self.uuid, started_at=0, - node=self.node, ports=ports) - - def test_expected_data(self): - sys_name_mapping = 'switch-1:physnet1,switch-2:physnet2' - cfg.CONF.set_override('switch_sys_name_mapping', sys_name_mapping, - group='port_physnet') - port = list(self.node_info.ports().values())[0] - physnet = self.hook.get_physnet(port, 'em1', self.data) - self.assertEqual(physnet, 'physnet1') - - def test_no_lldp_processed(self): - del self.data['all_interfaces']['em1']['lldp_processed'] - port = list(self.node_info.ports().values())[0] - physnet = self.hook.get_physnet(port, 'em1', self.data) - self.assertIsNone(physnet) - - def test_no_lldp_system_name(self): - proc_data = self.data['all_interfaces']['em1'] - del proc_data['lldp_processed']['switch_system_name'] - port = list(self.node_info.ports().values())[0] - physnet = self.hook.get_physnet(port, 'em1', self.data) - self.assertIsNone(physnet) - - def test_no_mapping(self): - sys_name_mapping = 'switch-2:physnet2' - cfg.CONF.set_override('switch_sys_name_mapping', sys_name_mapping, - group='port_physnet') - port = list(self.node_info.ports().values())[0] - physnet = self.hook.get_physnet(port, 'em1', self.data) - self.assertIsNone(physnet) - - def test_invalid_mapping(self): - sys_name_mapping = 'switch-2:physnet1,switch-2:physnet2' - cfg.CONF.set_override('switch_sys_name_mapping', sys_name_mapping, - group='port_physnet') - port = list(self.node_info.ports().values())[0] - self.assertRaises(ValueError, - self.hook.get_physnet, port, 'em1', self.data) diff --git a/stackhpc_inspector_plugins/tests/unit/test_utils.py b/stackhpc_inspector_plugins/tests/unit/test_utils.py deleted file mode 100644 index a84184f..0000000 --- a/stackhpc_inspector_plugins/tests/unit/test_utils.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright (c) 2017 StackHPC Ltd. -# -# 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. - -from ironic_inspector.test import base as test_base -import testtools - -from stackhpc_inspector_plugins import utils - - -class TestParseMappings(test_base.BaseTest): - # Adapted from neutron_lib.tests.unit.utils.test_helpers.TestParseMappings. - - def parse(self, mapping_list): - return utils.parse_mappings(mapping_list) - - def test_parse_mappings_fails_for_missing_separator(self): - with testtools.ExpectedException(ValueError): - self.parse(['key']) - - def test_parse_mappings_fails_for_missing_key(self): - with testtools.ExpectedException(ValueError): - self.parse([':val']) - - def test_parse_mappings_fails_for_missing_value(self): - with testtools.ExpectedException(ValueError): - self.parse(['key:']) - - def test_parse_mappings_fails_for_extra_separator(self): - with testtools.ExpectedException(ValueError): - self.parse(['key:val:junk']) - - def test_parse_mappings_fails_for_duplicate_key(self): - with testtools.ExpectedException(ValueError): - self.parse(['key:val1', 'key:val2']) - - def test_parse_mappings_succeeds_for_one_mapping(self): - self.assertEqual({'key': 'val'}, self.parse(['key:val'])) - - def test_parse_mappings_succeeds_for_n_mappings(self): - self.assertEqual({'key1': 'val1', 'key2': 'val2'}, - self.parse(['key1:val1', 'key2:val2'])) - - def test_parse_mappings_succeeds_for_duplicate_value(self): - self.assertEqual({'key1': 'val', 'key2': 'val'}, - self.parse(['key1:val', 'key2:val'])) - - def test_parse_mappings_succeeds_for_no_mappings(self): - self.assertEqual({}, self.parse([''])) diff --git a/stackhpc_inspector_plugins/utils.py b/stackhpc_inspector_plugins/utils.py deleted file mode 100644 index a7420a1..0000000 --- a/stackhpc_inspector_plugins/utils.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) 2017 StackHPC Ltd. -# -# 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. - - -def parse_mappings(mapping_list): - """Parse a list of mapping strings into a dictionary. - - Adapted from neutron_lib.utils.helpers.parse_mappings. - - :param mapping_list: A list of strings of the form ':'. - :returns: A dict mapping keys to values or to list of values. - :raises ValueError: Upon malformed data or duplicate keys. - """ - mappings = {} - for mapping in mapping_list: - mapping = mapping.strip() - if not mapping: - continue - split_result = mapping.split(':') - if len(split_result) != 2: - raise ValueError("Invalid mapping: '%s'" % mapping) - key = split_result[0].strip() - if not key: - raise ValueError("Missing key in mapping: '%s'" % mapping) - value = split_result[1].strip() - if not value: - raise ValueError("Missing value in mapping: '%s'" % mapping) - if key in mappings: - raise ValueError("Key %(key)s in mapping: '%(mapping)s' not " - "unique" % {'key': key, 'mapping': mapping}) - mappings[key] = value - return mappings diff --git a/test-requirements.txt b/test-requirements.txt index 622ee4b..1cf0247 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,13 +1,12 @@ # The order of packages is significant, because pip processes them in the order # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. -python-ironicclient # Apache-2.0 coverage!=4.4,>=4.0 # Apache-2.0 -hacking>=3.0.1,<3.1.0 # Apache-2.0 -mock>=2.0 # BSD fixtures>=3.0.0 # Apache-2.0/BSD testresources>=0.2.4 # Apache-2.0/BSD testscenarios>=0.4 # Apache-2.0/BSD -oslotest>=1.10.0 # Apache-2.0 -ironic_inspector<10.0.0;python_version<'3.0' # Apache-2.0 -ironic_inspector;python_version>='3.0' # Apache-2.0 +oslotest>=3.2.0 # Apache-2.0 +stestr>=2.0.0 # Apache-2.0 +ironic >=32.0.0 # Apache-2.0 +oslo_service +flake8 diff --git a/tox.ini b/tox.ini index 86012e1..26a2199 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,19 @@ [tox] -envlist = py36,py37,py38,py27,pep8 +envlist = py3.12,pep8 [testenv] usedevelop = True install_command = pip install -U {opts} {packages} -basepython = python3.8 +basepython = python3.12 deps = -c{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt commands = - coverage run --branch --include "stackhpc_inspector_plugins*" -m unittest discover stackhpc_inspector_plugins.tests.unit + coverage run --branch --source ./stackhpc_inspector_plugins/plugins -m unittest discover stackhpc_inspector_plugins.tests.unit coverage report -m --fail-under 90 setenv = PYTHONDONTWRITEBYTECODE=1 TZ=UTC -passenv = http_proxy HTTP_PROXY https_proxy HTTPS_PROXY no_proxy NO_PROXY [testenv:venv] commands = {posargs} @@ -28,35 +27,6 @@ commands = commands = flake8 stackhpc_inspector_plugins -[testenv:py27] -basepython = python2.7 -deps = - -c{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/train} - -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt - -[testenv:py38] -basepython = python3.8 -# FIXME: Use ussuri release until victoria is released. -deps = - -c{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/yoga} - -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt - -[testenv:py37] -basepython = python3.7 -deps = - -c{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/yoga} - -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt - -[testenv:py36] -basepython = python3.6 -deps = - -c{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/yoga} - -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt - [flake8] max-complexity=15 # [H106] Don’t put vim configuration in source files. @@ -64,6 +34,3 @@ max-complexity=15 # [H904] Delay string interpolations at logging calls. enable-extensions=H106,H203,H904 import-order-style = pep8 - -[hacking] -import_exceptions = ironicclient.exceptions,ironic_inspector.common.i18n