From c3ee90ddac70db25e03a172903ec3e3b7d031fff Mon Sep 17 00:00:00 2001 From: Mahnoor Asghar Date: Wed, 30 Aug 2023 14:08:02 -0200 Subject: [PATCH] Add inspection hooks Adds the 'local-link-connection' and 'parse-lldp' inspection hooks in the agent inspect interface for processing data received from the ramdisk at the /v1/continue_inspection endpoint. Change-Id: I540f03b961b858e8fc00cd4abbc905faa8f0c6c5 Story: #2010275 --- .../inspector/hooks/local_link_connection.py | 123 +++++ .../modules/inspector/hooks/parse_lldp.py | 87 ++++ .../drivers/modules/inspector/lldp_parsers.py | 364 +++++++++++++++ ironic/drivers/modules/inspector/lldp_tlvs.py | 365 +++++++++++++++ ironic/objects/port.py | 12 + .../hooks/test_local_link_connection.py | 178 ++++++++ .../inspector/hooks/test_parse_lldp.py | 422 ++++++++++++++++++ requirements.txt | 2 + setup.cfg | 2 + 9 files changed, 1555 insertions(+) create mode 100644 ironic/drivers/modules/inspector/hooks/local_link_connection.py create mode 100644 ironic/drivers/modules/inspector/hooks/parse_lldp.py create mode 100644 ironic/drivers/modules/inspector/lldp_parsers.py create mode 100644 ironic/drivers/modules/inspector/lldp_tlvs.py create mode 100644 ironic/tests/unit/drivers/modules/inspector/hooks/test_local_link_connection.py create mode 100644 ironic/tests/unit/drivers/modules/inspector/hooks/test_parse_lldp.py diff --git a/ironic/drivers/modules/inspector/hooks/local_link_connection.py b/ironic/drivers/modules/inspector/hooks/local_link_connection.py new file mode 100644 index 0000000000..ba7996de1c --- /dev/null +++ b/ironic/drivers/modules/inspector/hooks/local_link_connection.py @@ -0,0 +1,123 @@ +# 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 binascii + +from construct import core +import netaddr +from oslo_log import log as logging + +from ironic.common import exception +from ironic.drivers.modules.inspector.hooks import base +from ironic.drivers.modules.inspector import lldp_tlvs as tlv +import ironic.objects.port as ironic_port + + +LOG = logging.getLogger(__name__) +PORT_ID_ITEM_NAME = "port_id" +SWITCH_ID_ITEM_NAME = "switch_id" + + +class LocalLinkConnectionHook(base.InspectionHook): + """Hook to process mandatory LLDP packet fields""" + + dependencies = ['validate-interfaces'] + + def _get_local_link_patch(self, lldp_data, port, node_uuid): + local_link_connection = {} + + for tlv_type, tlv_value in lldp_data: + try: + data = bytearray(binascii.unhexlify(tlv_value)) + except binascii.Error: + LOG.warning('TLV value for TLV type %d is not in correct ' + 'format. Ensure that the TLV value is in ' + 'hexidecimal format when sent to ironic. Node: %s', + tlv_type, node_uuid) + return + + item = value = None + if tlv_type == tlv.LLDP_TLV_PORT_ID: + try: + port_id = tlv.PortId.parse(data) + except (core.MappingError, netaddr.AddrFormatError) as e: + LOG.warning('TLV parse error for Port ID for node %s: %s', + node_uuid, e) + return + + item = PORT_ID_ITEM_NAME + value = port_id.value.value if port_id.value else None + elif tlv_type == tlv.LLDP_TLV_CHASSIS_ID: + try: + chassis_id = tlv.ChassisId.parse(data) + except (core.MappingError, netaddr.AddrFormatError) as e: + LOG.warning('TLV parse error for Chassis ID for node %s: ' + '%s', node_uuid, e) + return + + # Only accept mac address for chassis ID + if 'mac_address' in chassis_id.subtype: + item = SWITCH_ID_ITEM_NAME + value = chassis_id.value.value + + if item is None or value is None: + continue + if item in port.local_link_connection: + continue + local_link_connection[item] = value + + try: + LOG.debug('Updating port %s for node %s', port.address, node_uuid) + for item in local_link_connection: + port.set_local_link_connection(item, + local_link_connection[item]) + port.save() + except exception.IronicException as e: + LOG.warning('Failed to update port %(uuid)s for node %(node)s. ' + 'Error: %(error)s', {'uuid': port.id, + 'node': node_uuid, + 'error': e}) + + def __call__(self, task, inventory, plugin_data): + """Process LLDP data and patch Ironic port local link connection. + + Process the non-vendor-specific LLDP packet fields for each NIC found + for a baremetal node, port ID and chassis ID. These fields, if found + and if valid, will be saved into the local link connection information + (port id and switch id) fields on the Ironic port that represents that + NIC. + """ + lldp_raw = plugin_data.get('lldp_raw') or {} + + for iface in inventory['interfaces']: + # The all_interfaces field in plugin_data is provided by the + # validate-interfaces hook, so it is a dependency for this hook (?) + if iface['name'] not in plugin_data.get('all_interfaces'): + continue + + mac_address = iface['mac_address'] + port = ironic_port.Port.get_by_address(task.context, mac_address) + if not port: + LOG.debug('Skipping LLDP processing for interface %s of node ' + '%s: matching port not found in Ironic.', + mac_address, task.node.uuid) + continue + + lldp_data = lldp_raw.get(iface['name']) or iface.get('lldp') + if lldp_data is None: + LOG.warning('No LLDP data found for interface %s of node %s', + mac_address, task.node.uuid) + continue + + # Parse raw lldp data + self._get_local_link_patch(lldp_data, port, task.node.uuid) diff --git a/ironic/drivers/modules/inspector/hooks/parse_lldp.py b/ironic/drivers/modules/inspector/hooks/parse_lldp.py new file mode 100644 index 0000000000..2f2f77e82e --- /dev/null +++ b/ironic/drivers/modules/inspector/hooks/parse_lldp.py @@ -0,0 +1,87 @@ +# 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. + +"""LLDP Processing Hook for basic TLVs""" + +import binascii + +from oslo_log import log as logging + +from ironic.drivers.modules.inspector.hooks import base +from ironic.drivers.modules.inspector import lldp_parsers + + +LOG = logging.getLogger(__name__) + + +class ParseLLDPHook(base.InspectionHook): + """Process LLDP packet fields and store them in plugin_data['parsed_lldp'] + + Convert binary LLDP information into a readable form. Loop through raw + LLDP TLVs and parse those from the basic management, 802.1, and 802.3 TLV + sets. Store parsed data in the plugin_data as a new parsed_lldp dictionary + with interface names as keys. + """ + + def _parse_lldp_tlvs(self, tlvs, node_uuid): + """Parse LLDP TLVs into a dictionary of name/value pairs + + :param tlvs: List of raw TLVs + :param node_uuid: UUID of the node being inspected + :returns: Dictionary of name/value pairs. The LLDP user-friendly + names, e.g. "switch_port_id" are the keys. + """ + # Generate name/value pairs for each TLV supported by this plugin. + parser = lldp_parsers.LLDPBasicMgmtParser(node_uuid) + + for tlv_type, tlv_value in tlvs: + try: + data = bytearray(binascii.a2b_hex(tlv_value)) + except TypeError as e: + LOG.warning( + 'TLV value for TLV type %(tlv_type)d is not in correct ' + 'format, value must be in hexadecimal: %(msg)s. Node: ' + '%(node)s', {'tlv_type': tlv_type, 'msg': e, + 'node': node_uuid}) + continue + + if parser.parse_tlv(tlv_type, data): + LOG.debug("Handled TLV type %d. Node: %s", tlv_type, node_uuid) + else: + LOG.debug("LLDP TLV type %d not handled. Node: %s", tlv_type, + node_uuid) + return parser.nv_dict + + def __call__(self, task, inventory, plugin_data): + """Process LLDP data and update plugin_data with processed data""" + + lldp_raw = plugin_data.get('lldp_raw') or {} + + for interface in inventory['interfaces']: + if_name = interface['name'] + tlvs = lldp_raw.get(if_name) or interface.get('lldp') + if tlvs is None: + LOG.warning("No LLDP Data found for interface %s of node %s", + if_name, task.node.uuid) + continue + + LOG.debug("Processing LLDP Data for interface %s of node %s", + if_name, task.node.uuid) + + # Store LLDP data per interface in plugin_data[parsed_lldp] + nv = self._parse_lldp_tlvs(tlvs, task.node.uuid) + if nv: + if plugin_data.get('parsed_lldp'): + plugin_data['parsed_lldp'].update({if_name: nv}) + else: + plugin_data['parsed_lldp'] = {if_name: nv} diff --git a/ironic/drivers/modules/inspector/lldp_parsers.py b/ironic/drivers/modules/inspector/lldp_parsers.py new file mode 100644 index 0000000000..e4fcfeb032 --- /dev/null +++ b/ironic/drivers/modules/inspector/lldp_parsers.py @@ -0,0 +1,364 @@ +# 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. + +""" Names and mapping functions used to map LLDP TLVs to name/value pairs """ + +import binascii + +from construct import core +import netaddr +from oslo_log import log as logging + +from ironic.common.i18n import _ +from ironic.drivers.modules.inspector import lldp_tlvs as tlv + +LOG = logging.getLogger(__name__) + + +# Names used in name/value pair from parsed TLVs +LLDP_CHASSIS_ID_NM = 'switch_chassis_id' +LLDP_PORT_ID_NM = 'switch_port_id' +LLDP_PORT_DESC_NM = 'switch_port_description' +LLDP_SYS_NAME_NM = 'switch_system_name' +LLDP_SYS_DESC_NM = 'switch_system_description' +LLDP_SWITCH_CAP_NM = 'switch_capabilities' +LLDP_CAP_SUPPORT_NM = 'switch_capabilities_support' +LLDP_CAP_ENABLED_NM = 'switch_capabilities_enabled' +LLDP_MGMT_ADDRESSES_NM = 'switch_mgmt_addresses' +LLDP_PORT_VLANID_NM = 'switch_port_untagged_vlan_id' +LLDP_PORT_PROT_NM = 'switch_port_protocol' +LLDP_PORT_PROT_VLAN_ENABLED_NM = 'switch_port_protocol_vlan_enabled' +LLDP_PORT_PROT_VLAN_SUPPORT_NM = 'switch_port_protocol_vlan_support' +LLDP_PORT_PROT_VLANIDS_NM = 'switch_port_protocol_vlan_ids' +LLDP_PORT_VLANS_NM = 'switch_port_vlans' +LLDP_PROTOCOL_IDENTITIES_NM = 'switch_protocol_identities' +LLDP_PORT_MGMT_VLANID_NM = 'switch_port_management_vlan_id' +LLDP_PORT_LINK_AGG_NM = 'switch_port_link_aggregation' +LLDP_PORT_LINK_AGG_ENABLED_NM = 'switch_port_link_aggregation_enabled' +LLDP_PORT_LINK_AGG_SUPPORT_NM = 'switch_port_link_aggregation_support' +LLDP_PORT_LINK_AGG_ID_NM = 'switch_port_link_aggregation_id' +LLDP_PORT_MAC_PHY_NM = 'switch_port_mac_phy_config' +LLDP_PORT_LINK_AUTONEG_ENABLED_NM = 'switch_port_autonegotiation_enabled' +LLDP_PORT_LINK_AUTONEG_SUPPORT_NM = 'switch_port_autonegotiation_support' +LLDP_PORT_CAPABILITIES_NM = 'switch_port_physical_capabilities' +LLDP_PORT_MAU_TYPE_NM = 'switch_port_mau_type' +LLDP_MTU_NM = 'switch_port_mtu' + + +class LLDPParser(object): + """Base class to handle parsing of LLDP TLVs + + Each class that inherits from this base class must provide a parser map. + Parser maps are used to associate a LLDP TLV with a function handler and + arguments necessary to parse the TLV and generate one or more name/value + pairs. Each LLDP TLV maps to a tuple with the following fields: + + function - Handler function to generate name/value pairs + + construct - Name of construct definition for TLV + + name - User-friendly name of TLV. For TLVs that generate only one + name/value pair, this is the name used + + len_check - Boolean indicating if length check should be done on construct + + It is valid to have a function handler of None, this is for TLVs that + are not mapped to a name/value pair (e.g.LLDP_TLV_TTL). + """ + + def __init__(self, node_uuid, nv=None): + """Create LLDPParser + + :param node_uuid - UUID of node being inspected + :param nv - dictionary of name/value pairs to use + """ + self.nv_dict = nv or {} + self.node_uuid = node_uuid + self.parser_map = {} + + def set_value(self, name, value): + """Set name value pair in dictionary + + The value for a name should not be changed if it exists. + """ + self.nv_dict.setdefault(name, value) + + def append_value(self, name, value): + """Add value to a list mapped to name""" + self.nv_dict.setdefault(name, []).append(value) + + def add_single_value(self, struct, name, data): + """Add a single name/value pair to the nv dictionary""" + self.set_value(name, struct.value) + + def add_nested_value(self, struct, name, data): + """Add a single nested name/value pair to the dictionary""" + self.set_value(name, struct.value.value) + + def parse_tlv(self, tlv_type, data): + """Parse TLVs from mapping table + + This functions takes the TLV type and the raw data for this TLV and + gets a tuple from the parser_map. The construct field in the tuple + contains the construct lib definition of the TLV which can be parsed + to access individual fields. Once the TLV is parsed, the handler + function for each TLV will store the individual fields as name/value + pairs in nv_dict. + + If the handler function does not exist, then no name/value pairs will + be added to nv_dict, but since the TLV was handled, True will be + returned. + + :param: tlv_type - type identifier for TLV + :param: data - raw TLV value + :returns: True if TLV in parser_map and data is valid, otherwise False. + """ + + s = self.parser_map.get(tlv_type) + if not s: + return False + + func = s[0] # handler + + if not func: + return True # TLV is handled + + try: + tlv_parser = s[1] + name = s[2] + check_len = s[3] + except KeyError as e: + LOG.warning("Key error in TLV table: %s. Node: %s", e, + self.node_uuid) + return False + + # Some constructs require a length validation to ensure that the + # proper number of bytes have been provided, for example when a + # BitStruct is used. + if check_len and (tlv_parser.sizeof() != len(data)): + LOG.warning("Invalid data for %(name)s expected len %(expect)d, " + "got %(actual)d. Node: %(node)s", + {'name': name, 'expect': tlv_parser.sizeof(), + 'actual': len(data), 'node': self.node_uuid}) + return False + + # Use the construct parser to parse the TLV so that its individual + # fields can be accessed + try: + struct = tlv_parser.parse(data) + except (core.ConstructError, netaddr.AddrFormatError) as e: + LOG.warning("TLV parse error: %s. Node: %s", e, self.node_uuid) + return False + + # Call functions with parsed structure + try: + func(struct, name, data) + except ValueError as e: + LOG.warning("TLV value error: %s. Node: %s", e, self.node_uuid) + return False + + return True + + def add_dot1_link_aggregation(self, struct, name, data): + """Add name/value pairs for TLV Dot1_LinkAggregationId + + This is in the base class since it can be used by both dot1 and dot3. + """ + + self.set_value(LLDP_PORT_LINK_AGG_ENABLED_NM, + struct.status.enabled) + self.set_value(LLDP_PORT_LINK_AGG_SUPPORT_NM, + struct.status.supported) + self.set_value(LLDP_PORT_LINK_AGG_ID_NM, struct.portid) + + +class LLDPBasicMgmtParser(LLDPParser): + """Class to handle parsing of 802.1AB Basic Management set + + This class will also handle 802.1Q and 802.3 OUI TLVs. + """ + def __init__(self, nv=None): + super(LLDPBasicMgmtParser, self).__init__(nv) + + self.parser_map = { + tlv.LLDP_TLV_CHASSIS_ID: + (self.add_nested_value, tlv.ChassisId, LLDP_CHASSIS_ID_NM, + False), + tlv.LLDP_TLV_PORT_ID: + (self.add_nested_value, tlv.PortId, LLDP_PORT_ID_NM, False), + tlv.LLDP_TLV_TTL: (None, None, None, False), + tlv.LLDP_TLV_PORT_DESCRIPTION: + (self.add_single_value, tlv.PortDesc, LLDP_PORT_DESC_NM, + False), + tlv.LLDP_TLV_SYS_NAME: + (self.add_single_value, tlv.SysName, LLDP_SYS_NAME_NM, False), + tlv.LLDP_TLV_SYS_DESCRIPTION: + (self.add_single_value, tlv.SysDesc, LLDP_SYS_DESC_NM, False), + tlv.LLDP_TLV_SYS_CAPABILITIES: + (self.add_capabilities, tlv.SysCapabilities, + LLDP_SWITCH_CAP_NM, True), + tlv.LLDP_TLV_MGMT_ADDRESS: + (self.add_mgmt_address, tlv.MgmtAddress, + LLDP_MGMT_ADDRESSES_NM, False), + tlv.LLDP_TLV_ORG_SPECIFIC: + (self.handle_org_specific_tlv, tlv.OrgSpecific, None, False), + tlv.LLDP_TLV_END_LLDPPDU: (None, None, None, False) + } + + def add_mgmt_address(self, struct, name, data): + """Handle LLDP_TLV_MGMT_ADDRESS + + There can be multiple Mgmt Address TLVs, store in list. + """ + if struct.address: + self.append_value(name, struct.address) + + def _get_capabilities_list(self, caps): + """Get capabilities from bit map""" + cap_map = [ + (caps.repeater, 'Repeater'), + (caps.bridge, 'Bridge'), + (caps.wlan, 'WLAN'), + (caps.router, 'Router'), + (caps.telephone, 'Telephone'), + (caps.docsis, 'DOCSIS cable device'), + (caps.station, 'Station only'), + (caps.cvlan, 'C-Vlan'), + (caps.svlan, 'S-Vlan'), + (caps.tpmr, 'TPMR')] + + return [cap for (bit, cap) in cap_map if bit] + + def add_capabilities(self, struct, name, data): + """Handle LLDP_TLV_SYS_CAPABILITIES""" + self.set_value(LLDP_CAP_SUPPORT_NM, + self._get_capabilities_list(struct.system)) + self.set_value(LLDP_CAP_ENABLED_NM, + self._get_capabilities_list(struct.enabled)) + + def handle_org_specific_tlv(self, struct, name, data): + """Handle Organizationally Unique ID TLVs + + This class supports 802.1Q and 802.3 OUI TLVs. + + See http://www.ieee802.org/1/pages/802.1Q-2014.html, Annex D + and http://standards.ieee.org/about/get/802/802.3.html + """ + oui = binascii.hexlify(struct.oui).decode() + subtype = struct.subtype + oui_data = data[4:] + + if oui == tlv.LLDP_802dot1_OUI: + parser = LLDPdot1Parser(self.node_uuid, self.nv_dict) + if parser.parse_tlv(subtype, oui_data): + LOG.debug("Handled 802.1 subtype %d", subtype) + else: + LOG.debug("Subtype %d not found for 802.1", subtype) + elif oui == tlv.LLDP_802dot3_OUI: + parser = LLDPdot3Parser(self.node_uuid, self.nv_dict) + if parser.parse_tlv(subtype, oui_data): + LOG.debug("Handled 802.3 subtype %d", subtype) + else: + LOG.debug("Subtype %d not found for 802.3", subtype) + else: + LOG.warning("Organizationally Unique ID %s not recognized for " + "node %s", oui, self.node_uuid) + + +class LLDPdot1Parser(LLDPParser): + """Class to handle parsing of 802.1Q TLVs""" + def __init__(self, node_uuid, nv=None): + super(LLDPdot1Parser, self).__init__(node_uuid, nv) + + self.parser_map = { + tlv.dot1_PORT_VLANID: + (self.add_single_value, tlv.Dot1_UntaggedVlanId, + LLDP_PORT_VLANID_NM, False), + tlv.dot1_PORT_PROTOCOL_VLANID: + (self.add_dot1_port_protocol_vlan, tlv.Dot1_PortProtocolVlan, + LLDP_PORT_PROT_NM, True), + tlv.dot1_VLAN_NAME: + (self.add_dot1_vlans, tlv.Dot1_VlanName, None, False), + tlv.dot1_PROTOCOL_IDENTITY: + (self.add_dot1_protocol_identities, tlv.Dot1_ProtocolIdentity, + LLDP_PROTOCOL_IDENTITIES_NM, False), + tlv.dot1_MANAGEMENT_VID: + (self.add_single_value, tlv.Dot1_MgmtVlanId, + LLDP_PORT_MGMT_VLANID_NM, False), + tlv.dot1_LINK_AGGREGATION: + (self.add_dot1_link_aggregation, tlv.Dot1_LinkAggregationId, + LLDP_PORT_LINK_AGG_NM, True) + } + + def add_dot1_port_protocol_vlan(self, struct, name, data): + """Handle dot1_PORT_PROTOCOL_VLANID""" + self.set_value(LLDP_PORT_PROT_VLAN_ENABLED_NM, struct.flags.enabled) + self.set_value(LLDP_PORT_PROT_VLAN_SUPPORT_NM, struct.flags.supported) + + # There can be multiple port/protocol vlans TLVs, store in list + self.append_value(LLDP_PORT_PROT_VLANIDS_NM, struct.vlanid) + + def add_dot1_vlans(self, struct, name, data): + """Handle dot1_VLAN_NAME + + There can be multiple VLAN TLVs, add dictionary entry with id/vlan + to list. + """ + vlan_dict = {} + vlan_dict['name'] = struct.vlan_name + vlan_dict['id'] = struct.vlanid + self.append_value(LLDP_PORT_VLANS_NM, vlan_dict) + + def add_dot1_protocol_identities(self, struct, name, data): + """Handle dot1_PROTOCOL_IDENTITY + + There can be multiple protocol ids TLVs, store in list + """ + self.append_value(LLDP_PROTOCOL_IDENTITIES_NM, + binascii.b2a_hex(struct.protocol).decode()) + + +class LLDPdot3Parser(LLDPParser): + """Class to handle parsing of 802.3 TLVs""" + def __init__(self, node_uuid, nv=None): + super(LLDPdot3Parser, self).__init__(node_uuid, nv) + + # Note that 802.3 link Aggregation has been deprecated and moved to + # 802.1 spec, but it is in the same format. Use the same function as + # dot1 handler. + self.parser_map = { + tlv.dot3_MACPHY_CONFIG_STATUS: + (self.add_dot3_macphy_config, tlv.Dot3_MACPhy_Config_Status, + LLDP_PORT_MAC_PHY_NM, True), + tlv.dot3_LINK_AGGREGATION: + (self.add_dot1_link_aggregation, tlv.Dot1_LinkAggregationId, + LLDP_PORT_LINK_AGG_NM, True), + tlv.dot3_MTU: + (self.add_single_value, tlv.Dot3_MTU, LLDP_MTU_NM, False) + } + + def add_dot3_macphy_config(self, struct, name, data): + """Handle dot3_MACPHY_CONFIG_STATUS""" + + try: + mau_type = tlv.OPER_MAU_TYPES[struct.mau_type] + except KeyError: + raise ValueError(_('Invalid index for mau type')) + + self.set_value(LLDP_PORT_LINK_AUTONEG_ENABLED_NM, + struct.autoneg.enabled) + self.set_value(LLDP_PORT_LINK_AUTONEG_SUPPORT_NM, + struct.autoneg.supported) + self.set_value(LLDP_PORT_CAPABILITIES_NM, + tlv.get_autoneg_cap(struct.pmd_autoneg)) + self.set_value(LLDP_PORT_MAU_TYPE_NM, mau_type) diff --git a/ironic/drivers/modules/inspector/lldp_tlvs.py b/ironic/drivers/modules/inspector/lldp_tlvs.py new file mode 100644 index 0000000000..ba0374cc6f --- /dev/null +++ b/ironic/drivers/modules/inspector/lldp_tlvs.py @@ -0,0 +1,365 @@ +# 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. + +""" Link Layer Discovery Protocol TLVs """ + +# See http://construct.readthedocs.io/en/latest/index.html + +import functools + +import construct +from construct import core +import netaddr +from oslo_log import log as logging + +LOG = logging.getLogger(__name__) + +# Constants defined according to 802.1AB-2016 LLDP spec +# https://standards.ieee.org/findstds/standard/802.1AB-2016.html + +# TLV types +LLDP_TLV_END_LLDPPDU = 0 +LLDP_TLV_CHASSIS_ID = 1 +LLDP_TLV_PORT_ID = 2 +LLDP_TLV_TTL = 3 +LLDP_TLV_PORT_DESCRIPTION = 4 +LLDP_TLV_SYS_NAME = 5 +LLDP_TLV_SYS_DESCRIPTION = 6 +LLDP_TLV_SYS_CAPABILITIES = 7 +LLDP_TLV_MGMT_ADDRESS = 8 +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_PORT_VLANID = 1 +dot1_PORT_PROTOCOL_VLANID = 2 +dot1_VLAN_NAME = 3 +dot1_PROTOCOL_IDENTITY = 4 +dot1_MANAGEMENT_VID = 6 +dot1_LINK_AGGREGATION = 7 + +# 802.3 defines from http://standards.ieee.org/about/get/802/802.3.html, +# section 79 +LLDP_802dot3_OUI = "00120f" +# Subtypes +dot3_MACPHY_CONFIG_STATUS = 1 +dot3_LINK_AGGREGATION = 3 # Deprecated, but still in use +dot3_MTU = 4 + + +def bytes_to_int(obj): + """Convert bytes to an integer + + :param: obj - array of bytes + """ + return functools.reduce(lambda x, y: x << 8 | y, obj) + + +def mapping_for_enum(mapping): + """Return tuple used for keys as a dict + + :param: mapping - dict with tuple as keys + """ + return dict(mapping.keys()) + + +def mapping_for_switch(mapping): + """Return dict from values + + :param: mapping - dict with tuple as keys + """ + return {key[0]: value for key, value in mapping.items()} + + +IPv4Address = core.ExprAdapter( + core.Byte[4], + encoder=lambda obj, ctx: netaddr.IPAddress(obj).words, + decoder=lambda obj, ctx: str(netaddr.IPAddress(bytes_to_int(obj))) +) + +IPv6Address = core.ExprAdapter( + core.Byte[16], + encoder=lambda obj, ctx: netaddr.IPAddress(obj).words, + decoder=lambda obj, ctx: str(netaddr.IPAddress(bytes_to_int(obj))) +) + +MACAddress = core.ExprAdapter( + core.Byte[6], + encoder=lambda obj, ctx: netaddr.EUI(obj).words, + decoder=lambda obj, ctx: str(netaddr.EUI(bytes_to_int(obj), + dialect=netaddr.mac_unix_expanded)) +) + +IANA_ADDRESS_FAMILY_ID_MAPPING = { + ('ipv4', 1): IPv4Address, + ('ipv6', 2): IPv6Address, + ('mac', 6): MACAddress, +} + +IANAAddress = core.Struct( + 'family' / core.Enum(core.Int8ub, **mapping_for_enum( + IANA_ADDRESS_FAMILY_ID_MAPPING)), + 'value' / core.Switch(construct.this.family, mapping_for_switch( + IANA_ADDRESS_FAMILY_ID_MAPPING))) + +# Note that 'GreedyString()' is used in cases where string len is not defined +CHASSIS_ID_MAPPING = { + ('entPhysAlias_c', 1): core.Struct('value' / core.GreedyString("utf8")), + ('ifAlias', 2): core.Struct('value' / core.GreedyString("utf8")), + ('entPhysAlias_p', 3): core.Struct('value' / core.GreedyString("utf8")), + ('mac_address', 4): core.Struct('value' / MACAddress), + ('IANA_address', 5): IANAAddress, + ('ifName', 6): core.Struct('value' / core.GreedyString("utf8")), + ('local', 7): core.Struct('value' / core.GreedyString("utf8")) +} + +# +# Basic Management Set TLV field definitions +# + +# Chassis ID value is based on the subtype +ChassisId = core.Struct( + 'subtype' / core.Enum(core.Byte, **mapping_for_enum( + CHASSIS_ID_MAPPING)), + 'value' / core.Switch(construct.this.subtype, + mapping_for_switch(CHASSIS_ID_MAPPING)) +) + +PORT_ID_MAPPING = { + ('ifAlias', 1): core.Struct('value' / core.GreedyString("utf8")), + ('entPhysicalAlias', 2): core.Struct('value' / core.GreedyString("utf8")), + ('mac_address', 3): core.Struct('value' / MACAddress), + ('IANA_address', 4): IANAAddress, + ('ifName', 5): core.Struct('value' / core.GreedyString("utf8")), + ('local', 7): core.Struct('value' / core.GreedyString("utf8")) +} + +# Port ID value is based on the subtype +PortId = core.Struct( + 'subtype' / core.Enum(core.Byte, **mapping_for_enum( + PORT_ID_MAPPING)), + 'value' / core.Switch(construct.this.subtype, + mapping_for_switch(PORT_ID_MAPPING)) +) + +PortDesc = core.Struct('value' / core.GreedyString("utf8")) + +SysName = core.Struct('value' / core.GreedyString("utf8")) + +SysDesc = core.Struct('value' / core.GreedyString("utf8")) + +MgmtAddress = core.Struct( + 'len' / core.Int8ub, + 'family' / core.Enum(core.Int8ub, **mapping_for_enum( + IANA_ADDRESS_FAMILY_ID_MAPPING)), + 'address' / core.Switch(construct.this.family, mapping_for_switch( + IANA_ADDRESS_FAMILY_ID_MAPPING)) +) + +Capabilities = core.BitStruct( + core.Padding(5), + 'tpmr' / core.Bit, + 'svlan' / core.Bit, + 'cvlan' / core.Bit, + 'station' / core.Bit, + 'docsis' / core.Bit, + 'telephone' / core.Bit, + 'router' / core.Bit, + 'wlan' / core.Bit, + 'bridge' / core.Bit, + 'repeater' / core.Bit, + core.Padding(1) +) + +SysCapabilities = core.Struct( + 'system' / Capabilities, + 'enabled' / Capabilities +) + +OrgSpecific = core.Struct( + 'oui' / core.Bytes(3), + 'subtype' / core.Int8ub +) + +# +# 802.1Q TLV field definitions +# See http://www.ieee802.org/1/pages/802.1Q-2014.html, Annex D +# + +Dot1_UntaggedVlanId = core.Struct('value' / core.Int16ub) + +Dot1_PortProtocolVlan = core.Struct( + 'flags' / core.BitStruct( + core.Padding(5), + 'enabled' / core.Flag, + 'supported' / core.Flag, + core.Padding(1), + ), + 'vlanid' / core.Int16ub +) + +Dot1_VlanName = core.Struct( + 'vlanid' / core.Int16ub, + 'name_len' / core.Rebuild(core.Int8ub, + construct.len_(construct.this.value)), + 'vlan_name' / core.PaddedString(construct.this.name_len, "utf8") +) + +Dot1_ProtocolIdentity = core.Struct( + 'len' / core.Rebuild(core.Int8ub, construct.len_(construct.this.value)), + 'protocol' / core.Bytes(construct.this.len) +) + +Dot1_MgmtVlanId = core.Struct('value' / core.Int16ub) + +Dot1_LinkAggregationId = core.Struct( + 'status' / core.BitStruct( + core.Padding(6), + 'enabled' / core.Flag, + 'supported' / core.Flag + ), + 'portid' / core.Int32ub +) + +# +# 802.3 TLV field definitions +# See http://standards.ieee.org/about/get/802/802.3.html, +# section 79 +# + + +def get_autoneg_cap(pmd): + """Get autonegotiated capability strings + + This returns a list of capability strings from the Physical Media + Dependent (PMD) capability bits. + + :param pmd: PMD bits + :return: Sorted ist containing capability strings + """ + caps_set = set() + + pmd_map = [ + (pmd._10base_t_hdx, '10BASE-T hdx'), + (pmd._10base_t_hdx, '10BASE-T fdx'), + (pmd._10base_t4, '10BASE-T4'), + (pmd._100base_tx_hdx, '100BASE-TX hdx'), + (pmd._100base_tx_fdx, '100BASE-TX fdx'), + (pmd._100base_t2_hdx, '100BASE-T2 hdx'), + (pmd._100base_t2_fdx, '100BASE-T2 fdx'), + (pmd.pause_fdx, 'PAUSE fdx'), + (pmd.asym_pause, 'Asym PAUSE fdx'), + (pmd.sym_pause, 'Sym PAUSE fdx'), + (pmd.asym_sym_pause, 'Asym and Sym PAUSE fdx'), + (pmd._1000base_x_hdx, '1000BASE-X hdx'), + (pmd._1000base_x_fdx, '1000BASE-X fdx'), + (pmd._1000base_t_hdx, '1000BASE-T hdx'), + (pmd._1000base_t_fdx, '1000BASE-T fdx')] + + for bit, cap in pmd_map: + if bit: + caps_set.add(cap) + + return sorted(caps_set) + + +Dot3_MACPhy_Config_Status = core.Struct( + 'autoneg' / core.BitStruct( + core.Padding(6), + 'enabled' / core.Flag, + 'supported' / core.Flag, + ), + # See IANAifMauAutoNegCapBits + # RFC 4836, Definitions of Managed Objects for IEEE 802.3 + 'pmd_autoneg' / core.BitStruct( + core.Padding(1), + '_10base_t_hdx' / core.Bit, + '_10base_t_fdx' / core.Bit, + '_10base_t4' / core.Bit, + '_100base_tx_hdx' / core.Bit, + '_100base_tx_fdx' / core.Bit, + '_100base_t2_hdx' / core.Bit, + '_100base_t2_fdx' / core.Bit, + 'pause_fdx' / core.Bit, + 'asym_pause' / core.Bit, + 'sym_pause' / core.Bit, + 'asym_sym_pause' / core.Bit, + '_1000base_x_hdx' / core.Bit, + '_1000base_x_fdx' / core.Bit, + '_1000base_t_hdx' / core.Bit, + '_1000base_t_fdx' / core.Bit + ), + 'mau_type' / core.Int16ub +) + +# See ifMauTypeList in +# RFC 4836, Definitions of Managed Objects for IEEE 802.3 +OPER_MAU_TYPES = { + 0: "Unknown", + 1: "AUI", + 2: "10BASE-5", + 3: "FOIRL", + 4: "10BASE-2", + 5: "10BASE-T duplex mode unknown", + 6: "10BASE-FP", + 7: "10BASE-FB", + 8: "10BASE-FL duplex mode unknown", + 9: "10BROAD36", + 10: "10BASE-T half duplex", + 11: "10BASE-T full duplex", + 12: "10BASE-FL half duplex", + 13: "10BASE-FL full duplex", + 14: "100 BASE-T4", + 15: "100BASE-TX half duplex", + 16: "100BASE-TX full duplex", + 17: "100BASE-FX half duplex", + 18: "100BASE-FX full duplex", + 19: "100BASE-T2 half duplex", + 20: "100BASE-T2 full duplex", + 21: "1000BASE-X half duplex", + 22: "1000BASE-X full duplex", + 23: "1000BASE-LX half duplex", + 24: "1000BASE-LX full duplex", + 25: "1000BASE-SX half duplex", + 26: "1000BASE-SX full duplex", + 27: "1000BASE-CX half duplex", + 28: "1000BASE-CX full duplex", + 29: "1000BASE-T half duplex", + 30: "1000BASE-T full duplex", + 31: "10GBASE-X", + 32: "10GBASE-LX4", + 33: "10GBASE-R", + 34: "10GBASE-ER", + 35: "10GBASE-LR", + 36: "10GBASE-SR", + 37: "10GBASE-W", + 38: "10GBASE-EW", + 39: "10GBASE-LW", + 40: "10GBASE-SW", + 41: "10GBASE-CX4", + 42: "2BASE-TL", + 43: "10PASS-TS", + 44: "100BASE-BX10D", + 45: "100BASE-BX10U", + 46: "100BASE-LX10", + 47: "1000BASE-BX10D", + 48: "1000BASE-BX10U", + 49: "1000BASE-LX10", + 50: "1000BASE-PX10D", + 51: "1000BASE-PX10U", + 52: "1000BASE-PX20D", + 53: "1000BASE-PX20U", +} + +Dot3_MTU = core.Struct('value' / core.Int16ub) diff --git a/ironic/objects/port.py b/ironic/objects/port.py index 8f6f7ddf0a..d961a6e3eb 100644 --- a/ironic/objects/port.py +++ b/ironic/objects/port.py @@ -491,6 +491,18 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): """ return cls.supports_version((1, 9)) + def set_local_link_connection(self, key, value): + """Set a `local_link_connection` value. + + Setting a `local_link_connection` dict value via this method ensures + that this field will be flagged for saving. + + :param key: Key of item to set + :param value: Value of item to set + """ + self.local_link_connection[key] = value + self._changed_fields.add('local_link_connection') + @base.IronicObjectRegistry.register class PortCRUDNotification(notification.NotificationBase): diff --git a/ironic/tests/unit/drivers/modules/inspector/hooks/test_local_link_connection.py b/ironic/tests/unit/drivers/modules/inspector/hooks/test_local_link_connection.py new file mode 100644 index 0000000000..d5b224b62e --- /dev/null +++ b/ironic/tests/unit/drivers/modules/inspector/hooks/test_local_link_connection.py @@ -0,0 +1,178 @@ +# 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 copy +from unittest import mock + +from oslo_utils import uuidutils + +from ironic.conductor import task_manager +from ironic.conf import CONF +from ironic.drivers.modules.inspector.hooks import local_link_connection as \ + hook +from ironic.objects import port +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as obj_utils + + +_INVENTORY = { + 'interfaces': [{ + 'name': 'em1', + 'mac_address': '11:11:11:11:11:11', + 'ipv4_address': '1.1.1.1', + 'lldp': [(0, ''), + (1, '04885a92ec5459'), + (2, '0545746865726e6574312f3138'), + (3, '0078')] + }] +} + + +class LocalLinkConnectionTestCase(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 = copy.deepcopy(_INVENTORY) + self.plugin_data = {'all_interfaces': {'em1': {}}} + self.port = obj_utils.create_test_port( + self.context, uuid=uuidutils.generate_uuid(), node_id=self.node.id, + address='11:11:11:11:11:11', local_link_connection={}) + + @mock.patch.object(port.Port, 'save', autospec=True) + @mock.patch.object(port.Port, 'get_by_address', autospec=True) + def test_valid_data(self, mock_get_port, mock_port_save): + mock_get_port.return_value = self.port + with task_manager.acquire(self.context, self.node.id) as task: + hook.LocalLinkConnectionHook().__call__(task, self.inventory, + self.plugin_data) + self.assertTrue(mock_port_save.called) + self.assertEqual({'switch_id': '88:5a:92:ec:54:59', + 'port_id': 'Ethernet1/18'}, + self.port.local_link_connection) + + @mock.patch.object(port.Port, 'save', autospec=True) + @mock.patch.object(port.Port, 'get_by_address', autospec=True) + def test_lldp_none(self, mock_get_port, mock_port_save): + self.inventory['interfaces'][0]['lldp'] = None + mock_get_port.return_value = self.port + with task_manager.acquire(self.context, self.node.id) as task: + hook.LocalLinkConnectionHook().__call__(task, self.inventory, + self.plugin_data) + self.assertFalse(mock_port_save.called) + self.assertEqual(self.port.local_link_connection, {}) + + @mock.patch.object(port.Port, 'save', autospec=True) + def test_interface_not_in_all_interfaces(self, mock_port_save): + self.plugin_data['all_interfaces'] = {} + with task_manager.acquire(self.context, self.node.id) as task: + hook.LocalLinkConnectionHook().__call__(task, self.inventory, + self.plugin_data) + self.assertFalse(mock_port_save.called) + self.assertEqual(self.port.local_link_connection, {}) + + @mock.patch.object(hook.LOG, 'debug', autospec=True) + @mock.patch.object(port.Port, 'get_by_address', autospec=True) + @mock.patch.object(port.Port, 'save', autospec=True) + def test_no_port_in_ironic(self, mock_port_save, mock_get_port, mock_log): + mock_get_port.return_value = None + with task_manager.acquire(self.context, self.node.id) as task: + hook.LocalLinkConnectionHook().__call__(task, self.inventory, + self.plugin_data) + self.assertFalse(mock_port_save.called) + self.assertEqual(self.port.local_link_connection, {}) + mock_log.assert_called_once_with( + 'Skipping LLDP processing for interface %s of node %s: ' + 'matching port not found in Ironic.', + self.inventory['interfaces'][0]['mac_address'], + task.node.uuid) + + @mock.patch.object(port.Port, 'save', autospec=True) + @mock.patch.object(port.Port, 'get_by_address', autospec=True) + def test_port_local_link_connection_already_exists(self, + mock_get_port, + mock_port_save): + self.port['local_link_connection'] = {'switch_id': '11:11:11:11:11:11', + 'port_id': 'Ether'} + mock_get_port.return_value = self.port + + with task_manager.acquire(self.context, self.node.id) as task: + hook.LocalLinkConnectionHook().__call__(task, self.inventory, + self.plugin_data) + self.assertTrue(mock_port_save.called) + self.assertEqual(self.port.local_link_connection, + {'switch_id': '11:11:11:11:11:11', + 'port_id': 'Ether'}) + + @mock.patch.object(port.Port, 'save', autospec=True) + @mock.patch.object(hook.LOG, 'warning', autospec=True) + @mock.patch.object(port.Port, 'get_by_address', autospec=True) + def test_invalid_tlv_value_hex_format(self, mock_get_port, mock_log, + mock_port_save): + self.inventory['interfaces'][0]['lldp'] = [(2, 'weee')] + mock_get_port.return_value = self.port + with task_manager.acquire(self.context, self.node.id) as task: + hook.LocalLinkConnectionHook().__call__(task, self.inventory, + self.plugin_data) + mock_log.assert_called_once_with( + 'TLV value for TLV type %d is not in correct format. Ensure ' + 'that the TLV value is in hexidecimal format when sent to ' + 'ironic. Node: %s', 2, task.node.uuid) + self.assertFalse(mock_port_save.called) + self.assertEqual(self.port.local_link_connection, {}) + + @mock.patch.object(port.Port, 'save', autospec=True) + @mock.patch.object(port.Port, 'get_by_address', autospec=True) + def test_invalid_port_id_subtype(self, mock_get_port, mock_port_save): + # First byte of TLV value is processed to calculate the subtype for + # the port ID, Subtype 6 ('06...') isn't a subtype supported by this + # hook, so we expect it to skip this TLV. + self.inventory['interfaces'][0]['lldp'][2] = ( + 2, '0645746865726e6574312f3138') + mock_get_port.return_value = self.port + with task_manager.acquire(self.context, self.node.id) as task: + hook.LocalLinkConnectionHook().__call__(task, self.inventory, + self.plugin_data) + self.assertTrue(mock_port_save.called) + self.assertEqual(self.port.local_link_connection, + {'switch_id': '88:5a:92:ec:54:59'}) + + @mock.patch.object(port.Port, 'save', autospec=True) + @mock.patch.object(port.Port, 'get_by_address', autospec=True) + def test_port_id_subtype_mac(self, mock_get_port, mock_port_save): + self.inventory['interfaces'][0]['lldp'][2] = ( + 2, '03885a92ec5458') + mock_get_port.return_value = self.port + with task_manager.acquire(self.context, self.node.id) as task: + hook.LocalLinkConnectionHook().__call__(task, self.inventory, + self.plugin_data) + self.assertTrue(mock_port_save.called) + self.assertEqual(self.port.local_link_connection, + {'port_id': '88:5a:92:ec:54:58', + 'switch_id': '88:5a:92:ec:54:59'}) + + @mock.patch.object(port.Port, 'save', autospec=True) + @mock.patch.object(port.Port, 'get_by_address', autospec=True) + def test_invalid_chassis_id_subtype(self, mock_get_port, mock_port_save): + # First byte of TLV value is processed to calculate the subtype for + # the chassis ID, Subtype 5 ('05...') isn't a subtype supported by + # this hook, so we expect it to skip this TLV. + self.inventory['interfaces'][0]['lldp'][1] = (1, '05885a92ec5459') + mock_get_port.return_value = self.port + with task_manager.acquire(self.context, self.node.id) as task: + hook.LocalLinkConnectionHook().__call__(task, self.inventory, + self.plugin_data) + self.assertTrue(mock_port_save.called) + self.assertEqual({'port_id': 'Ethernet1/18'}, + self.port.local_link_connection) diff --git a/ironic/tests/unit/drivers/modules/inspector/hooks/test_parse_lldp.py b/ironic/tests/unit/drivers/modules/inspector/hooks/test_parse_lldp.py new file mode 100644 index 0000000000..8513af2109 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/inspector/hooks/test_parse_lldp.py @@ -0,0 +1,422 @@ +# 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 unittest import mock + +from ironic.conductor import task_manager +from ironic.conf import CONF +from ironic.drivers.modules.inspector.hooks import parse_lldp as hook +from ironic.drivers.modules.inspector import lldp_parsers as nv +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as obj_utils + + +class ParseLLDPTestCase(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 = { + 'interfaces': [{ + 'name': 'em1', + }], + 'cpu': 1, + 'disks': 1, + 'memory': 1 + } + self.ip = '1.2.1.2' + self.mac = '11:22:33:44:55:66' + self.plugin_data = {'all_interfaces': + {'em1': {'mac': self.mac, + 'ip': self.ip}}} + self.expected = {'em1': {'ip': self.ip, 'mac': self.mac}} + + def test_all_valid_data(self): + self.plugin_data['lldp_raw'] = { + 'em1': [ + [1, "04112233aabbcc"], # ChassisId + [2, "07373334"], # PortId + [3, "003c"], # TTL + [4, "686f737430322e6c61622e656e6720706f7274203320" + "28426f6e6429"], # PortDesc + [5, "737730312d646973742d31622d623132"], # SysName + [6, "4e6574776f726b732c20496e632e20353530302c2076657273696f" + "6e203132204275696c6420646174653a20323031342d30332d31332030" + "383a33383a33302055544320"], # SysDesc + [7, "00140014"], # SysCapabilities + [8, "0501c000020f020000000000"], # MgmtAddress + [8, "110220010db885a3000000008a2e03707334020000000000"], + [8, "0706aa11bb22cc3302000003e900"], # MgmtAddress + [127, "00120f01036c110010"], # dot3 MacPhyConfigStatus + [127, "00120f030300000002"], # dot3 LinkAggregation + [127, "00120f0405ea"], # dot3 MTU + [127, "0080c2010066"], # dot1 PortVlan + [127, "0080c20206000a"], # dot1 PortProtocolVlanId + [127, "0080c202060014"], # dot1 PortProtocolVlanId + [127, "0080c204080026424203000000"], # dot1 ProtocolIdentity + [127, "0080c203006507766c616e313031"], # dot1 VlanName + [127, "0080c203006607766c616e313032"], # dot1 VlanName + [127, "0080c203006807766c616e313034"], # dot1 VlanName + [127, "0080c2060058"], # dot1 MgmtVID + [0, ""], + ] + } + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [[0, ""]] + }] + + expected = { + nv.LLDP_CAP_ENABLED_NM: ['Bridge', 'Router'], + nv.LLDP_CAP_SUPPORT_NM: ['Bridge', 'Router'], + nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:cc", + nv.LLDP_MGMT_ADDRESSES_NM: ['192.0.2.15', + '2001:db8:85a3::8a2e:370:7334', + 'aa:11:bb:22:cc:33'], + nv.LLDP_PORT_LINK_AUTONEG_ENABLED_NM: True, + nv.LLDP_PORT_DESC_NM: 'host02.lab.eng port 3 (Bond)', + nv.LLDP_PORT_ID_NM: '734', + nv.LLDP_PORT_LINK_AGG_ENABLED_NM: True, + nv.LLDP_PORT_LINK_AGG_ID_NM: 2, + nv.LLDP_PORT_LINK_AGG_SUPPORT_NM: True, + nv.LLDP_PORT_MGMT_VLANID_NM: 88, + nv.LLDP_PORT_MAU_TYPE_NM: '100BASE-TX full duplex', + nv.LLDP_MTU_NM: 1514, + nv.LLDP_PORT_CAPABILITIES_NM: ['1000BASE-T fdx', + '100BASE-TX fdx', + '100BASE-TX hdx', + '10BASE-T fdx', + '10BASE-T hdx', + 'Asym and Sym PAUSE fdx'], + nv.LLDP_PORT_PROT_VLAN_ENABLED_NM: True, + nv.LLDP_PORT_PROT_VLANIDS_NM: [10, 20], + nv.LLDP_PORT_PROT_VLAN_SUPPORT_NM: True, + nv.LLDP_PORT_VLANID_NM: 102, + nv.LLDP_PORT_VLANS_NM: [{'id': 101, 'name': 'vlan101'}, + {'id': 102, 'name': 'vlan102'}, + {'id': 104, "name": 'vlan104'}], + nv.LLDP_PROTOCOL_IDENTITIES_NM: ['0026424203000000'], + nv.LLDP_SYS_DESC_NM: 'Networks, Inc. 5500, version 12' + ' Build date: 2014-03-13 08:38:30 UTC ', + nv.LLDP_SYS_NAME_NM: 'sw01-dist-1b-b12' + } + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + actual = self.plugin_data.get('parsed_lldp').get('em1') + + for name, value in expected.items(): + if name is nv.LLDP_PORT_VLANS_NM: + for d1, d2 in zip(expected[name], actual[name]): + for key, value in d1.items(): + self.assertEqual(d2[key], value) + else: + self.assertEqual(actual[name], expected[name]) + + def test_old_format(self): + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [1, "04112233aabbcc"], # ChassisId + [2, "07373334"], # PortId + [3, "003c"], # TTL + [4, "686f737430322e6c61622e656e6720706f7274203320" + "28426f6e6429"], # PortDesc + [5, "737730312d646973742d31622d623132"], # SysName + [6, "4e6574776f726b732c20496e632e20353530302c2076657273696f" + "6e203132204275696c6420646174653a20323031342d30332d31332030" + "383a33383a33302055544320"], # SysDesc + [7, "00140014"], # SysCapabilities + [8, "0501c000020f020000000000"], # MgmtAddress + [8, "110220010db885a3000000008a2e03707334020000000000"], + [8, "0706aa11bb22cc3302000003e900"], # MgmtAddress + [127, "00120f01036c110010"], # dot3 MacPhyConfigStatus + [127, "00120f030300000002"], # dot3 LinkAggregation + [127, "00120f0405ea"], # dot3 MTU + [127, "0080c2010066"], # dot1 PortVlan + [127, "0080c20206000a"], # dot1 PortProtocolVlanId + [127, "0080c202060014"], # dot1 PortProtocolVlanId + [127, "0080c204080026424203000000"], # dot1 ProtocolIdentity + [127, "0080c203006507766c616e313031"], # dot1 VlanName + [127, "0080c203006607766c616e313032"], # dot1 VlanName + [127, "0080c203006807766c616e313034"], # dot1 VlanName + [127, "0080c2060058"], # dot1 MgmtVID + [0, ""]] + }] + + expected = { + nv.LLDP_CAP_ENABLED_NM: ['Bridge', 'Router'], + nv.LLDP_CAP_SUPPORT_NM: ['Bridge', 'Router'], + nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:cc", + nv.LLDP_MGMT_ADDRESSES_NM: ['192.0.2.15', + '2001:db8:85a3::8a2e:370:7334', + 'aa:11:bb:22:cc:33'], + nv.LLDP_PORT_LINK_AUTONEG_ENABLED_NM: True, + nv.LLDP_PORT_DESC_NM: 'host02.lab.eng port 3 (Bond)', + nv.LLDP_PORT_ID_NM: '734', + nv.LLDP_PORT_LINK_AGG_ENABLED_NM: True, + nv.LLDP_PORT_LINK_AGG_ID_NM: 2, + nv.LLDP_PORT_LINK_AGG_SUPPORT_NM: True, + nv.LLDP_PORT_MGMT_VLANID_NM: 88, + nv.LLDP_PORT_MAU_TYPE_NM: '100BASE-TX full duplex', + nv.LLDP_MTU_NM: 1514, + nv.LLDP_PORT_CAPABILITIES_NM: ['1000BASE-T fdx', + '100BASE-TX fdx', + '100BASE-TX hdx', + '10BASE-T fdx', + '10BASE-T hdx', + 'Asym and Sym PAUSE fdx'], + nv.LLDP_PORT_PROT_VLAN_ENABLED_NM: True, + nv.LLDP_PORT_PROT_VLANIDS_NM: [10, 20], + nv.LLDP_PORT_PROT_VLAN_SUPPORT_NM: True, + nv.LLDP_PORT_VLANID_NM: 102, + nv.LLDP_PORT_VLANS_NM: [{'id': 101, 'name': 'vlan101'}, + {'id': 102, 'name': 'vlan102'}, + {'id': 104, "name": 'vlan104'}], + nv.LLDP_PROTOCOL_IDENTITIES_NM: ['0026424203000000'], + nv.LLDP_SYS_DESC_NM: 'Networks, Inc. 5500, version 12 ' + 'Build date: 2014-03-13 08:38:30 UTC ', + nv.LLDP_SYS_NAME_NM: 'sw01-dist-1b-b12' + } + + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + actual = self.plugin_data['parsed_lldp']['em1'] + for name, value in expected.items(): + if name is nv.LLDP_PORT_VLANS_NM: + for d1, d2 in zip(expected[name], actual[name]): + for key, value in d1.items(): + self.assertEqual(d2[key], value) + else: + self.assertEqual(actual[name], expected[name]) + + def test_multiple_interfaces(self): + self.inventory = { + # An artificial mix of old and new LLDP fields. + 'interfaces': [ + { + 'name': 'em1' + }, + { + 'name': 'em2', + 'lldp': [ + [1, "04112233aabbdd"], + [2, "07373838"], + [3, "003c"] + ] + }, + { + 'name': 'em3', + 'lldp': [[3, "003c"]] + } + ], + 'cpu': 1, + 'disks': 1, + 'memory': 1 + } + self.plugin_data = { + 'all_interfaces': { + 'em1': {'mac': self.mac, 'ip': self.ip}, + 'em2': {'mac': self.mac, 'ip': self.ip}, + 'em3': {'mac': self.mac, 'ip': self.ip} + }, + 'lldp_raw': { + 'em1': [ + [1, "04112233aabbcc"], + [2, "07373334"], + [3, "003c"] + ], + 'em3': [ + [1, "04112233aabbee"], + [2, "07373939"], + [3, "003c"] + ], + } + } + expected = {"em1": {nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:cc", + nv.LLDP_PORT_ID_NM: "734"}, + "em2": {nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:dd", + nv.LLDP_PORT_ID_NM: "788"}, + "em3": {nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:ee", + nv.LLDP_PORT_ID_NM: "799"}} + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertEqual(expected, self.plugin_data['parsed_lldp']) + + def test_chassis_ids(self): + # Test IPv4 address + self.inventory['interfaces'] = [ + { + 'name': 'em1', + 'lldp': [[1, '0501c000020f']] + }, + { + 'name': 'em2', + 'lldp': [[1, '0773773031']] + } + ] + self.expected = { + 'em1': {nv.LLDP_CHASSIS_ID_NM: '192.0.2.15'}, + 'em2': {nv.LLDP_CHASSIS_ID_NM: "sw01"} + } + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertEqual(self.expected, self.plugin_data['parsed_lldp']) + + def test_duplicate_tlvs(self): + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [1, "04112233aabbcc"], # ChassisId + [1, "04332211ddeeff"], # ChassisId + [1, "04556677aabbcc"], # ChassisId + [2, "07373334"], # PortId + [2, "07373435"], # PortId + [2, "07373536"] # PortId + ]}] + # Only the first unique TLV is processed + self.expected = {'em1': { + nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:cc", + nv.LLDP_PORT_ID_NM: "734" + }} + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertEqual(self.expected, self.plugin_data['parsed_lldp']) + + def test_unhandled_tlvs(self): + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [10, "04112233aabbcc"], + [12, "07373334"], + [128, "00120f080300010000"]]}] + # Nothing should be written to lldp_processed + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertNotIn('parsed_lldp', self.plugin_data) + + def test_unhandled_oui(self): + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [127, "00906901425030323134323530393236"], + [127, "23ac0074657374"], + [127, "00120e010300010000"]]}] + # Nothing should be written to lldp_processed + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertNotIn('parsed_lldp', self.plugin_data) + + @mock.patch.object(nv.LOG, 'warning', autospec=True) + def test_null_strings(self, mock_log): + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [1, "04"], + [4, ""], # PortDesc + [5, ""], # SysName + [6, ""], # SysDesc + [127, "0080c203006507"] # dot1 VlanName + ]}] + self.expected = {'em1': { + nv.LLDP_PORT_DESC_NM: '', + nv.LLDP_SYS_DESC_NM: '', + nv.LLDP_SYS_NAME_NM: '' + }} + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertEqual(self.expected, self.plugin_data['parsed_lldp']) + self.assertEqual(2, mock_log.call_count) + + @mock.patch.object(nv.LOG, 'warning', autospec=True) + def test_truncated_int(self, mock_log): + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [127, "00120f04"], # dot3 MTU + [127, "0080c201"], # dot1 PortVlan + [127, "0080c206"], # dot1 MgmtVID + ] + }] + # Nothing should be written to lldp_processed + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertNotIn('parsed_lldp', self.plugin_data) + self.assertEqual(3, mock_log.call_count) + + @mock.patch.object(nv.LOG, 'warning', autospec=True) + def test_invalid_ip(self, mock_log): + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [8, "0501"], # truncated + [8, "0507c000020f020000000000"] + ] # invalid id + }] + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertNotIn('parsed_lldp', self.plugin_data) + self.assertEqual(1, mock_log.call_count) + + @mock.patch.object(nv.LOG, 'warning', autospec=True) + def test_truncated_mac(self, mock_log): + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [[8, "0506"]] + }] + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertNotIn('parsed_lldp', self.plugin_data) + self.assertEqual(1, mock_log.call_count) + + @mock.patch.object(nv.LOG, 'warning', autospec=True) + def test_bad_value_macphy(self, mock_log): + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [127, "00120f01036c11FFFF"], # invalid mau type + [127, "00120f01036c11"], # truncated + [127, "00120f01036c"] # truncated + ] + }] + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertNotIn('parsed_lldp', self.plugin_data) + self.assertEqual(3, mock_log.call_count) + + @mock.patch.object(nv.LOG, 'warning', autospec=True) + def test_bad_value_linkagg(self, mock_log): + self.inventory['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [127, "00120f0303"], # dot3 LinkAggregation + [127, "00120f03"] # truncated + ] + }] + with task_manager.acquire(self.context, self.node.id) as task: + hook.ParseLLDPHook().__call__(task, self.inventory, + self.plugin_data) + self.assertNotIn('parsed_lldp', self.plugin_data) + self.assertEqual(2, mock_log.call_count) diff --git a/requirements.txt b/requirements.txt index e57c720491..aa0e212b30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -48,3 +48,5 @@ futurist>=1.2.0 # Apache-2.0 tooz>=2.7.0 # Apache-2.0 openstacksdk>=0.48.0 # Apache-2.0 sushy>=4.3.0 +construct>=2.9.39 # MIT +netaddr>=0.9.0 # BSD diff --git a/setup.cfg b/setup.cfg index 78f3de67d1..4e0b239e67 100644 --- a/setup.cfg +++ b/setup.cfg @@ -211,6 +211,8 @@ ironic.inspection.hooks = physical-network = ironic.drivers.modules.inspector.hooks.physical_network:PhysicalNetworkHook raid-device = ironic.drivers.modules.inspector.hooks.raid_device:RaidDeviceHook root-device = ironic.drivers.modules.inspector.hooks.root_device:RootDeviceHook + local-link-connection = ironic.drivers.modules.inspector.hooks.local_link_connection:LocalLinkConnectionHook + parse-lldp = ironic.drivers.modules.inspector.hooks.parse_lldp:ParseLLDPHook [egg_info] tag_build =