From 92e2d26f158e543f7eca09376058e0819f195b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Jens=C3=A5s?= Date: Thu, 9 Apr 2020 01:45:16 +0200 Subject: [PATCH] Port physical network CIDR map hook The physnet_cidr_map hook adds functionality to populate the physical_network of a baremetal port based on a cidr-to-physnet mapping in configuration. Related-Bug: #1870529 Change-Id: I43cdac5ccd8c46836b26f6e4bc0d4509958e2e79 --- doc/source/user/usage.rst | 8 + ironic_inspector/conf/__init__.py | 2 + ironic_inspector/conf/opts.py | 1 + ironic_inspector/conf/port_physnet.py | 36 ++++ ironic_inspector/plugins/physnet_cidr_map.py | 65 ++++++++ .../unit/test_plugins_physnet_cidr_map.py | 155 ++++++++++++++++++ ...hysnet-cidr-map-hook-b38bf8051ad5ba69.yaml | 6 + setup.cfg | 1 + 8 files changed, 274 insertions(+) create mode 100644 ironic_inspector/conf/port_physnet.py create mode 100644 ironic_inspector/plugins/physnet_cidr_map.py create mode 100644 ironic_inspector/test/unit/test_plugins_physnet_cidr_map.py create mode 100644 releasenotes/notes/physnet-cidr-map-hook-b38bf8051ad5ba69.yaml diff --git a/doc/source/user/usage.rst b/doc/source/user/usage.rst index 65b70bf07..ff539f3cd 100644 --- a/doc/source/user/usage.rst +++ b/doc/source/user/usage.rst @@ -249,6 +249,14 @@ Here are some plugins that can be additionally enabled: Processes LLDP data returned from inspection and parses TLVs from the Basic Management (802.1AB), 802.1Q, and 802.3 sets and stores the processed data back to the Ironic inspector data in Swift. +``physnet_cidr_map`` + Configures the ``physical_network`` property of the nodes Ironic port when + the IP address is in a configured CIDR mapping. CIDR to physical network + mappings is set in configuration using the ``[port_physnet]/cidr_map`` + option, for example:: + + [port_physnet] + cidr_map = 10.10.10.0/24:physnet_a, 2001:db8::/64:physnet_b Refer to :ref:`contributing_link` for information on how to write your own plugin. diff --git a/ironic_inspector/conf/__init__.py b/ironic_inspector/conf/__init__.py index bba61f731..e467336df 100644 --- a/ironic_inspector/conf/__init__.py +++ b/ironic_inspector/conf/__init__.py @@ -20,6 +20,7 @@ from ironic_inspector.conf import dnsmasq_pxe_filter from ironic_inspector.conf import iptables from ironic_inspector.conf import ironic from ironic_inspector.conf import pci_devices +from ironic_inspector.conf import port_physnet from ironic_inspector.conf import processing from ironic_inspector.conf import pxe_filter from ironic_inspector.conf import service_catalog @@ -37,6 +38,7 @@ dnsmasq_pxe_filter.register_opts(CONF) iptables.register_opts(CONF) ironic.register_opts(CONF) pci_devices.register_opts(CONF) +port_physnet.register_opts(CONF) processing.register_opts(CONF) pxe_filter.register_opts(CONF) service_catalog.register_opts(CONF) diff --git a/ironic_inspector/conf/opts.py b/ironic_inspector/conf/opts.py index efc30e19b..6384d9d25 100644 --- a/ironic_inspector/conf/opts.py +++ b/ironic_inspector/conf/opts.py @@ -68,6 +68,7 @@ def list_opts(): ('swift', ironic_inspector.conf.swift.list_opts()), ('ironic', ironic_inspector.conf.ironic.list_opts()), ('iptables', ironic_inspector.conf.iptables.list_opts()), + ('port_physnet', ironic_inspector.conf.port_physnet.list_opts()), ('processing', ironic_inspector.conf.processing.list_opts()), ('pci_devices', ironic_inspector.conf.pci_devices.list_opts()), ('pxe_filter', ironic_inspector.conf.pxe_filter.list_opts()), diff --git a/ironic_inspector/conf/port_physnet.py b/ironic_inspector/conf/port_physnet.py new file mode 100644 index 000000000..6dfcf7ab7 --- /dev/null +++ b/ironic_inspector/conf/port_physnet.py @@ -0,0 +1,36 @@ +# 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 oslo_config import cfg + +from ironic_inspector.common.i18n import _ + + +_OPTS = [ + cfg.ListOpt('cidr_map', + default=[], + sample_default=('10.10.10.0/24:physnet_a,' + '2001:db8::/64:physnet_b'), + help=_('Mapping of IP subnet CIDR to physical network. When ' + 'the physnet_cidr_map processing hook is enabled the ' + 'physical_network property of baremetal ports is ' + 'populated based on this mapping.')), +] + + +def register_opts(conf): + conf.register_opts(_OPTS, group='port_physnet') + + +def list_opts(): + return _OPTS diff --git a/ironic_inspector/plugins/physnet_cidr_map.py b/ironic_inspector/plugins/physnet_cidr_map.py new file mode 100644 index 000000000..9e75899b5 --- /dev/null +++ b/ironic_inspector/plugins/physnet_cidr_map.py @@ -0,0 +1,65 @@ +# 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. + +"""Port Physical Network Hook""" + +import ipaddress + +from oslo_config import cfg + +from ironic_inspector.plugins import base_physnet + +CONF = cfg.CONF + + +class PhysnetCidrMapHook(base_physnet.BasePhysnetHook): + """Process port physical network + + Set the physical_network field of baremetal ports based on a cidr to + physical network mapping in the configuration. + """ + + 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. + """ + + def get_iface_ips(iface): + ips = [] + for addr_version in ['ipv4_address', 'ipv6_address']: + try: + ips.append(ipaddress.ip_address(iface.get(addr_version))) + except ValueError: + pass + + return ips + + # Convert list config to a dict with ip_networks as keys + cidr_map = { + ipaddress.ip_network(x.rsplit(':', 1)[0]): x.rsplit(':', 1)[1] + for x in CONF.port_physnet.cidr_map} + + iface = [i for i in introspection_data['inventory']['interfaces'] + if i['name'] == iface_name][0] + ips = get_iface_ips(iface) + + for ip in ips: + try: + return [cidr_map[cidr] for cidr in cidr_map if ip in cidr][0] + except IndexError: + # No mapping found for any of the ip addresses + return None diff --git a/ironic_inspector/test/unit/test_plugins_physnet_cidr_map.py b/ironic_inspector/test/unit/test_plugins_physnet_cidr_map.py new file mode 100644 index 000000000..c45dfd8b4 --- /dev/null +++ b/ironic_inspector/test/unit/test_plugins_physnet_cidr_map.py @@ -0,0 +1,155 @@ +# 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 openstack import exceptions +from oslo_config import cfg + +from ironic_inspector import node_cache +from ironic_inspector.plugins import physnet_cidr_map +from ironic_inspector.test import base as test_base +from ironic_inspector import utils + + +class TestPhysnetCidrMapHook(test_base.NodeTest): + hook = physnet_cidr_map.PhysnetCidrMapHook() + + def setUp(self): + super(TestPhysnetCidrMapHook, self).setUp() + 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': {}, + } + } + + ports = [mock.Mock(spec=['address', 'uuid', 'physical_network'], + address=a) 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): + cfg.CONF.set_override('cidr_map', '1.1.1.0/24:physnet_a', + group='port_physnet') + patches = [{'path': '/physical_network', + 'value': 'physnet_a', + '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_no_matching_mapping_config(self, mock_patch): + cfg.CONF.set_override('cidr_map', '2.2.2.0/24:physnet_b', + group='port_physnet') + self.hook.before_update(self.data, self.node_info) + self.assertFalse(mock_patch.called) + + @mock.patch.object(node_cache.NodeInfo, 'patch_port') + def test_expected_data_ipv6_lowercase(self, mock_patch): + self.data['inventory']['interfaces'][0].pop('ipv4_address') + self.data['inventory']['interfaces'][0]['ipv6_address'] = '2001:db8::1' + cfg.CONF.set_override('cidr_map', '2001:db8::/64:physnet_b', + group='port_physnet') + patches = [{'path': '/physical_network', + 'value': 'physnet_b', + '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_expected_data_ipv6_uppercase(self, mock_patch): + self.data['inventory']['interfaces'][0].pop('ipv4_address') + self.data['inventory']['interfaces'][0]['ipv6_address'] = '2001:db8::1' + cfg.CONF.set_override('cidr_map', '2001:DB8::/64:physnet_b', + group='port_physnet') + patches = [{'path': '/physical_network', + 'value': 'physnet_b', + '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_no_mapping_in_config(self, mock_patch): + self.hook.before_update(self.data, self.node_info) + self.assertFalse(mock_patch.called) + + def test_no_inventory(self): + cfg.CONF.set_override('cidr_map', '1.1.1.0/24:physnet_a', + group='port_physnet') + del self.data['inventory'] + self.assertRaises(utils.Error, self.hook.before_update, + self.data, self.node_info) + + @mock.patch('ironic_inspector.plugins.base_physnet.LOG') + @mock.patch.object(node_cache.NodeInfo, 'patch_port') + def test_interface_not_in_ironic(self, mock_patch, mock_log): + cfg.CONF.set_override('cidr_map', '1.1.1.0/24:physnet_a', + group='port_physnet') + self.node_info._ports = {} + self.hook.before_update(self.data, self.node_info) + self.assertTrue(mock_log.debug.called) + + @mock.patch.object(node_cache.NodeInfo, 'patch_port') + def test_no_overwrite(self, mock_patch): + ports = [mock.Mock(spec=['address', 'uuid', 'physical_network'], + address=a, physical_network='foo') + for a in ('11:11:11:11:11:11',)] + node_info = node_cache.NodeInfo(uuid=self.uuid, started_at=0, + node=self.node, ports=ports) + cfg.CONF.set_override('overwrite_existing', False, group='processing') + cfg.CONF.set_override('cidr_map', '1.1.1.0/24:physnet_a', + group='port_physnet') + self.hook.before_update(self.data, node_info) + self.assertFalse(mock_patch.called) + + @mock.patch('ironic_inspector.plugins.base_physnet.LOG') + @mock.patch.object(node_cache.NodeInfo, 'patch_port') + def test_patch_port_exception(self, mock_patch, mock_log): + cfg.CONF.set_override('cidr_map', '1.1.1.0/24:physnet_a', + group='port_physnet') + mock_patch.side_effect = exceptions.BadRequestException('invalid data') + self.hook.before_update(self.data, self.node_info) + log_msg = "Failed to update port %(uuid)s: %(error)s" + mock_log.warning.assert_called_with(log_msg, mock.ANY, + node_info=mock.ANY) + + @mock.patch.object(node_cache.NodeInfo, 'patch_port') + def test_no_ip_address_on_interface(self, mock_patch): + cfg.CONF.set_override('cidr_map', '1.1.1.0/24:physnet_a', + group='port_physnet') + data = { + 'inventory': { + 'interfaces': [{ + 'name': 'em1', + 'mac_address': '11:11:11:11:11:11', + }], + 'cpu': 1, + 'disks': 1, + 'memory': 1 + }, + 'all_interfaces': { + 'em1': {}, + } + } + self.hook.before_update(data, self.node_info) + self.assertFalse(mock_patch.called) diff --git a/releasenotes/notes/physnet-cidr-map-hook-b38bf8051ad5ba69.yaml b/releasenotes/notes/physnet-cidr-map-hook-b38bf8051ad5ba69.yaml new file mode 100644 index 000000000..2d571954b --- /dev/null +++ b/releasenotes/notes/physnet-cidr-map-hook-b38bf8051ad5ba69.yaml @@ -0,0 +1,6 @@ +--- +features: + - Added ``physnet_cidr_map`` processing plugin, the plugin uses the IP + address of interfaces returned during inspection and set the port + ``physical_network`` via lookup from a CIDR to physical network mapping in + config option ``[port_physnet]/cidr_map``. diff --git a/setup.cfg b/setup.cfg index 236601d6c..b039e8dc8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,6 +43,7 @@ ironic_inspector.hooks.processing = local_link_connection = ironic_inspector.plugins.local_link_connection:GenericLocalLinkConnectionHook lldp_basic = ironic_inspector.plugins.lldp_basic:LLDPBasicProcessingHook pci_devices = ironic_inspector.plugins.pci_devices:PciDevicesHook + physnet_cidr_map = ironic_inspector.plugins.physnet_cidr_map:PhysnetCidrMapHook ironic_inspector.hooks.node_not_found = example = ironic_inspector.plugins.example:example_not_found_hook enroll = ironic_inspector.plugins.discovery:enroll_node_not_found_hook