diff --git a/ironic_python_agent/config.py b/ironic_python_agent/config.py index 442666030..bb28737d4 100644 --- a/ironic_python_agent/config.py +++ b/ironic_python_agent/config.py @@ -291,6 +291,15 @@ cli_opts = [ 'This is an advanced override setting which may only ' 'be useful if the environment requires API version ' 'auto-detection to be disabled or blocked.'), + cfg.StrOpt('enable_vlan_interfaces', + default=APARAMS.get('ipa-enable-vlan-interfaces', ''), + help='Comma-separated list of VLAN interfaces to enable, ' + 'in the format "interface.vlan". If only an ' + 'interface is provided, then IPA should attempt to ' + 'bring up all VLANs on that interface detected ' + 'via lldp. If "all" is set then IPA should attempt ' + 'to bring up all VLANs from lldp on all interfaces. ' + 'By default, no VLANs will be brought up.'), ] CONF.register_cli_opts(cli_opts) diff --git a/ironic_python_agent/hardware.py b/ironic_python_agent/hardware.py index 9e13688af..04296adb3 100644 --- a/ironic_python_agent/hardware.py +++ b/ironic_python_agent/hardware.py @@ -1056,6 +1056,12 @@ class GenericHardwareManager(HardwareManager): as None. """ global WARN_BIOSDEVNAME_NOT_FOUND + + if self._is_vlan(interface_name): + LOG.debug('Interface %s is a VLAN, biosdevname not called', + interface_name) + return + try: stdout, _ = utils.execute('biosdevname', '-i', interface_name) @@ -1078,10 +1084,19 @@ class GenericHardwareManager(HardwareManager): interface_name) return os.path.exists(device_path) + def _is_vlan(self, interface_name): + # A VLAN interface does not have /device, check naming convention + # used when adding VLAN interface + + interface, sep, vlan = interface_name.partition('.') + + return vlan.isdigit() + def list_network_interfaces(self): network_interfaces_list = [] iface_names = os.listdir('{}/class/net'.format(self.sys_path)) - iface_names = [name for name in iface_names if self._is_device(name)] + iface_names = [name for name in iface_names + if self._is_vlan(name) or self._is_device(name)] if CONF.collect_lldp: self.lldp_data = dispatch_to_managers('collect_lldp_data', @@ -1093,6 +1108,16 @@ class GenericHardwareManager(HardwareManager): result.lldp = self._get_lldp_data(iface_name) network_interfaces_list.append(result) + # If configured, bring up vlan interfaces. If the actual vlans aren't + # defined they are derived from LLDP data + if CONF.enable_vlan_interfaces: + vlan_iface_names = netutils.bring_up_vlan_interfaces( + network_interfaces_list) + for vlan_iface_name in vlan_iface_names: + result = dispatch_to_managers( + 'get_interface_info', interface_name=vlan_iface_name) + network_interfaces_list.append(result) + return network_interfaces_list def get_cpus(self): diff --git a/ironic_python_agent/netutils.py b/ironic_python_agent/netutils.py index 785e81d3e..0b595d969 100644 --- a/ironic_python_agent/netutils.py +++ b/ironic_python_agent/netutils.py @@ -24,6 +24,8 @@ from oslo_config import cfg from oslo_log import log as logging from oslo_utils import netutils +from ironic_python_agent import utils + LOG = logging.getLogger(__name__) CONF = cfg.CONF @@ -33,6 +35,14 @@ SIOCGIFFLAGS = 0x8913 SIOCSIFFLAGS = 0x8914 INFINIBAND_ADDR_LEN = 59 +# LLDP definitions needed to extract vlan information +LLDP_TLV_ORG_SPECIFIC = 127 +# 802.1Q defines from http://www.ieee802.org/1/pages/802.1Q-2014.html, Annex D +LLDP_802dot1_OUI = "0080c2" +# subtypes +dot1_VLAN_NAME = "03" +VLAN_ID_LEN = len(LLDP_802dot1_OUI + dot1_VLAN_NAME) + class ifreq(ctypes.Structure): """Class for setting flags on a socket.""" @@ -258,3 +268,110 @@ def get_wildcard_address(): if netutils.is_ipv6_enabled(): return "::" return "0.0.0.0" + + +def _get_configured_vlans(): + return [x.strip() for x in CONF.enable_vlan_interfaces.split(',') + if x.strip()] + + +def _add_vlan_interface(interface, vlan, interfaces_list): + + vlan_name = interface + '.' + vlan + + # if any(x for x in interfaces_list if x.name == vlan_name): + if any(x.name == vlan_name for x in interfaces_list): + LOG.info("VLAN interface %s has already been added", vlan_name) + return '' + + try: + LOG.info('Adding VLAN interface %s', vlan_name) + # Add the interface + utils.execute('ip', 'link', 'add', 'link', interface, 'name', + vlan_name, 'type', 'vlan', 'id', vlan, + check_exit_code=[0, 2]) + + # Bring up interface + utils.execute('ip', 'link', 'set', 'dev', vlan_name, 'up') + + except Exception as exc: + LOG.warning('Exception when running ip commands to add VLAN ' + 'interface: %s', exc) + return '' + + return vlan_name + + +def _add_vlans_from_lldp(lldp, interface, interfaces_list): + interfaces = [] + + # Get the lldp packets received on this interface + if lldp: + for type, value in lldp: + if (type == LLDP_TLV_ORG_SPECIFIC + and value.startswith(LLDP_802dot1_OUI + + dot1_VLAN_NAME)): + vlan = str(int(value[VLAN_ID_LEN: VLAN_ID_LEN + 4], 16)) + name = _add_vlan_interface(interface, vlan, + interfaces_list) + if name: + interfaces.append(name) + else: + LOG.debug('VLAN interface %s does not have lldp info', interface) + + return interfaces + + +def bring_up_vlan_interfaces(interfaces_list): + """Bring up vlan interfaces based on kernel params + + Use the configured value of ``enable_vlan_interfaces`` to determine + if VLAN interfaces should be brought up using ``ip`` commands. If + ``enable_vlan_interfaces`` defines a particular vlan then bring up + that vlan. If it defines an interface or ``all`` then use LLDP info + to figure out which VLANs should be brought up. + + :param interfaces_list: List of current interfaces + :return: List of vlan interface names that have been added + """ + interfaces = [] + vlan_interfaces = _get_configured_vlans() + for vlan_int in vlan_interfaces: + # TODO(bfournie) skip if pxe boot interface + if '.' in vlan_int: + # interface and vlan are provided + interface, vlan = vlan_int.split('.', 1) + if any(x.name == interface for x in interfaces_list): + name = _add_vlan_interface(interface, vlan, + interfaces_list) + if name: + interfaces.append(name) + else: + LOG.warning('Provided VLAN interface %s does not exist', + interface) + elif CONF.collect_lldp: + # Get the vlans from lldp info + if vlan_int == 'all': + # Use all interfaces + for iface in interfaces_list: + names = _add_vlans_from_lldp( + iface.lldp, iface.name, interfaces_list) + if names: + interfaces.extend(names) + else: + # Use provided interface + lldp = next((x.lldp for x in interfaces_list + if x.name == vlan_int), None) + if lldp: + names = _add_vlans_from_lldp(lldp, vlan_int, + interfaces_list) + if names: + interfaces.extend(names) + else: + LOG.warning('Provided interface name %s was not found', + vlan_int) + else: + LOG.warning('Attempting to add VLAN interfaces but specific ' + 'interface not provided and LLDP not enabled') + + return interfaces diff --git a/ironic_python_agent/tests/unit/test_hardware.py b/ironic_python_agent/tests/unit/test_hardware.py index afa1b96fd..832b17c6e 100644 --- a/ironic_python_agent/tests/unit/test_hardware.py +++ b/ironic_python_agent/tests/unit/test_hardware.py @@ -1318,6 +1318,186 @@ class TestGenericHardwareManager(base.IronicAgentTest): self.assertEqual('0x1014', interfaces[0].product) self.assertEqual('em0', interfaces[0].biosdevname) + @mock.patch('ironic_python_agent.hardware.get_managers', autospec=True) + @mock.patch('netifaces.ifaddresses', autospec=True) + @mock.patch('os.listdir', autospec=True) + @mock.patch('os.path.exists', autospec=True) + @mock.patch('builtins.open', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) + @mock.patch.object(netutils, 'get_mac_addr', autospec=True) + @mock.patch.object(netutils, 'interface_has_carrier', autospec=True) + def test_list_network_vlan_interfaces(self, + mock_has_carrier, + mock_get_mac, + mocked_execute, + mocked_open, + mocked_exists, + mocked_listdir, + mocked_ifaddresses, + mockedget_managers): + mockedget_managers.return_value = [hardware.GenericHardwareManager()] + CONF.set_override('enable_vlan_interfaces', 'eth0.100') + mocked_listdir.return_value = ['lo', 'eth0'] + mocked_exists.side_effect = [False, True, False] + mocked_open.return_value.__enter__ = lambda s: s + mocked_open.return_value.__exit__ = mock.Mock() + read_mock = mocked_open.return_value.read + read_mock.side_effect = ['1'] + mocked_ifaddresses.return_value = { + netifaces.AF_INET: [{'addr': '192.168.1.2'}], + netifaces.AF_INET6: [{'addr': 'fd00::101'}] + } + mocked_execute.return_value = ('em0\n', '') + mock_get_mac.mock_has_carrier = True + mock_get_mac.return_value = '00:0c:29:8c:11:b1' + interfaces = self.hardware.list_network_interfaces() + self.assertEqual(2, len(interfaces)) + self.assertEqual('eth0', interfaces[0].name) + self.assertEqual('00:0c:29:8c:11:b1', interfaces[0].mac_address) + self.assertEqual('192.168.1.2', interfaces[0].ipv4_address) + self.assertEqual('fd00::101', interfaces[0].ipv6_address) + self.assertIsNone(interfaces[0].lldp) + self.assertEqual('eth0.100', interfaces[1].name) + self.assertEqual('00:0c:29:8c:11:b1', interfaces[1].mac_address) + self.assertIsNone(interfaces[1].lldp) + + @mock.patch('ironic_python_agent.hardware.get_managers', autospec=True) + @mock.patch('ironic_python_agent.netutils.get_lldp_info', autospec=True) + @mock.patch('netifaces.ifaddresses', autospec=True) + @mock.patch('os.listdir', autospec=True) + @mock.patch('os.path.exists', autospec=True) + @mock.patch('builtins.open', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) + @mock.patch.object(netutils, 'get_mac_addr', autospec=True) + @mock.patch.object(netutils, 'interface_has_carrier', autospec=True) + def test_list_network_vlan_interfaces_using_lldp(self, + mock_has_carrier, + mock_get_mac, + mocked_execute, + mocked_open, + mocked_exists, + mocked_listdir, + mocked_ifaddresses, + mocked_lldp_info, + mockedget_managers): + mockedget_managers.return_value = [hardware.GenericHardwareManager()] + CONF.set_override('collect_lldp', True) + CONF.set_override('enable_vlan_interfaces', 'eth0') + mocked_listdir.return_value = ['lo', 'eth0'] + mocked_execute.return_value = ('em0\n', '') + mocked_exists.side_effect = [False, True, False] + mocked_open.return_value.__enter__ = lambda s: s + mocked_open.return_value.__exit__ = mock.Mock() + read_mock = mocked_open.return_value.read + read_mock.side_effect = ['1'] + mocked_lldp_info.return_value = {'eth0': [ + (0, b''), + (127, b'\x00\x80\xc2\x03\x00d\x08vlan-100'), + (127, b'\x00\x80\xc2\x03\x00e\x08vlan-101')] + } + mock_has_carrier.return_value = True + mock_get_mac.return_value = '00:0c:29:8c:11:b1' + interfaces = self.hardware.list_network_interfaces() + self.assertEqual(3, len(interfaces)) + self.assertEqual('eth0', interfaces[0].name) + self.assertEqual('00:0c:29:8c:11:b1', interfaces[0].mac_address) + expected_lldp_info = [ + (0, ''), + (127, "0080c203006408766c616e2d313030"), + (127, "0080c203006508766c616e2d313031") + ] + self.assertEqual(expected_lldp_info, interfaces[0].lldp) + self.assertEqual('eth0.100', interfaces[1].name) + self.assertEqual('00:0c:29:8c:11:b1', interfaces[1].mac_address) + self.assertIsNone(interfaces[1].lldp) + self.assertEqual('eth0.101', interfaces[2].name) + self.assertEqual('00:0c:29:8c:11:b1', interfaces[2].mac_address) + self.assertIsNone(interfaces[2].lldp) + + @mock.patch.object(netutils, 'LOG', autospec=True) + @mock.patch('ironic_python_agent.hardware.get_managers', autospec=True) + @mock.patch('netifaces.ifaddresses', autospec=True) + @mock.patch('os.listdir', autospec=True) + @mock.patch('os.path.exists', autospec=True) + @mock.patch('builtins.open', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) + @mock.patch.object(netutils, 'get_mac_addr', autospec=True) + @mock.patch.object(netutils, 'interface_has_carrier', autospec=True) + def test_list_network_vlan_invalid_int(self, + mock_has_carrier, + mock_get_mac, + mocked_execute, + mocked_open, + mocked_exists, + mocked_listdir, + mocked_ifaddresses, + mockedget_managers, + mocked_log): + mockedget_managers.return_value = [hardware.GenericHardwareManager()] + CONF.set_override('collect_lldp', True) + CONF.set_override('enable_vlan_interfaces', 'enp0s1') + mocked_listdir.return_value = ['lo', 'eth0'] + mocked_exists.side_effect = [False, True, False] + mocked_open.return_value.__enter__ = lambda s: s + mocked_open.return_value.__exit__ = mock.Mock() + read_mock = mocked_open.return_value.read + read_mock.side_effect = ['1'] + mocked_ifaddresses.return_value = { + netifaces.AF_INET: [{'addr': '192.168.1.2'}], + netifaces.AF_INET6: [{'addr': 'fd00::101'}] + } + mocked_execute.return_value = ('em0\n', '') + mock_get_mac.mock_has_carrier = True + mock_get_mac.return_value = '00:0c:29:8c:11:b1' + + self.hardware.list_network_interfaces() + mocked_log.warning.assert_called_once_with( + 'Provided interface name %s was not found', 'enp0s1') + + @mock.patch('ironic_python_agent.hardware.get_managers', autospec=True) + @mock.patch('ironic_python_agent.netutils.get_lldp_info', autospec=True) + @mock.patch('os.listdir', autospec=True) + @mock.patch('os.path.exists', autospec=True) + @mock.patch('builtins.open', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) + @mock.patch.object(netutils, 'get_mac_addr', autospec=True) + def test_list_network_vlan_interfaces_using_lldp_all(self, + mock_get_mac, + mocked_execute, + mocked_open, + mocked_exists, + mocked_listdir, + mocked_lldp_info, + mockedget_managers): + mockedget_managers.return_value = [hardware.GenericHardwareManager()] + CONF.set_override('collect_lldp', True) + CONF.set_override('enable_vlan_interfaces', 'all') + mocked_listdir.return_value = ['lo', 'eth0', 'eth1'] + mocked_execute.return_value = ('em0\n', '') + mocked_exists.side_effect = [False, True, True] + mocked_open.return_value.__enter__ = lambda s: s + mocked_open.return_value.__exit__ = mock.Mock() + read_mock = mocked_open.return_value.read + read_mock.side_effect = ['1'] + mocked_lldp_info.return_value = {'eth0': [ + (0, b''), + (127, b'\x00\x80\xc2\x03\x00d\x08vlan-100'), + (127, b'\x00\x80\xc2\x03\x00e\x08vlan-101')], + 'eth1': [ + (0, b''), + (127, b'\x00\x80\xc2\x03\x00f\x08vlan-102'), + (127, b'\x00\x80\xc2\x03\x00g\x08vlan-103')] + } + + interfaces = self.hardware.list_network_interfaces() + self.assertEqual(6, len(interfaces)) + self.assertEqual('eth0', interfaces[0].name) + self.assertEqual('eth1', interfaces[1].name) + self.assertEqual('eth0.100', interfaces[2].name) + self.assertEqual('eth0.101', interfaces[3].name) + self.assertEqual('eth1.102', interfaces[4].name) + self.assertEqual('eth1.103', interfaces[5].name) + @mock.patch.object(os, 'readlink', autospec=True) @mock.patch.object(os, 'listdir', autospec=True) @mock.patch.object(hardware, 'get_cached_node', autospec=True) diff --git a/releasenotes/notes/add-vlan-interfaces-cdfeb39d0f3d444d.yaml b/releasenotes/notes/add-vlan-interfaces-cdfeb39d0f3d444d.yaml new file mode 100644 index 000000000..608495e88 --- /dev/null +++ b/releasenotes/notes/add-vlan-interfaces-cdfeb39d0f3d444d.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + Adds the ability to bring up VLAN interfaces and include them in the + introspection report. A new kernel params field is added - + ``ipa-enable-vlan-interfaces``, which defines either the VLAN interface + to enable, the interface to use, or 'all' - which indicates all + interfaces. If the particular VLAN is not provided, IPA will + use the LLDP information for the interface to determine which VLANs should + be enabled. See + `story 2008298 `_. +