diff --git a/doc/source/usage.rst b/doc/source/usage.rst index ac5fc7a18..ecfffc6c4 100644 --- a/doc/source/usage.rst +++ b/doc/source/usage.rst @@ -192,6 +192,10 @@ Here are some plugins that can be additionally enabled: information on the nodes Ironic ports with that data. To enable LLDP in the inventory from IPA ``ipa-collect-lldp=1`` should be passed as a kernel parameter to the IPA ramdisk. +``lldp_basic`` + 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. Refer to :ref:`contributing_link` for information on how to write your own plugin. diff --git a/ironic_inspector/common/lldp_parsers.py b/ironic_inspector/common/lldp_parsers.py new file mode 100644 index 000000000..49f4615e6 --- /dev/null +++ b/ironic_inspector/common/lldp_parsers.py @@ -0,0 +1,339 @@ +# 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 ironic_inspector.common.i18n import _, _LW +from ironic_inspector.common import lldp_tlvs as tlv +from ironic_inspector import utils + +LOG = utils.getProcessingLogger(__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""" + + def __init__(self, node_info, nv=None): + """Create LLDPParser + + :param node_info - node being introspected + :param nv - dictionary of name/value pairs to use + """ + if not nv: + self.nv_dict = {} + else: + self.nv_dict = nv + + self.node_info = node_info + + # 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 values: + # 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 that indicates whether a len check + # should be done on the construct + # + # Its 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). + # + # Each class that inherits from this base class must provide a + # parser map. + + self.parser_map = {} + + def set_value(self, name, value): + """Set name value pair in dictionary""" + self.nv_dict.setdefault(name, value) # don't change key if it exists + + 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 the the nv dict""" + self.set_value(name, struct.value) + + def parse_tlv(self, tlv_type, data): + """Parse TLVs from mapping table + + :param: tlv_type - type identifier for TLV + :param: data - raw TLV value + """ + + # The handler function will generate name/value pairs using the + # tlv construct definition. If the function does not exist, then no + # name/value pairs will be added, but since the TLV was handled, + # True will be returned + s = self.parser_map.get(tlv_type) + if s: + func = s[0] # handler + if func: + try: + tlv_parser = s[1] + name = s[2] + check_len = s[3] + except KeyError as e: + LOG.warning(_LW("Key error in TLV table: %s"), e, + node_info=self.node_info) + return False + + # Some constructs require a length validation to ensure the + # proper number of bytes has been provided, for example + # when a BitStruct is used. + if check_len and (tlv_parser.sizeof() != len(data)): + LOG.warning(_LW('Invalid data for %(name)s ' + 'expected len %(expect)d, got %(actual)d'), + {'name': name, 'expect': tlv_parser.sizeof(), + 'actual': len(data)}) + return False + + # Use the construct parser to parse TLV so that it's + # individual fields can be accessed + try: + struct = tlv_parser.parse(data) + except (core.RangeError, core.FieldError, core.MappingError, + netaddr.AddrFormatError) as e: + LOG.warning(_LW("TLV parse error: %s"), e, + node_info=self.node_info) + return False + + # Call functions with parsed structure + try: + func(struct, name, data) + except ValueError as e: + LOG.warning(_LW("TLV value error: %s"), e, + node_info=self.node_info) + return True + + return False + + # This method is in base class since it can be used by both dot1 and dot3 + def add_dot1_link_aggregation(self, struct, name, data): + + 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_single_value, tlv.ChassisId, + LLDP_CHASSIS_ID_NM, False), + tlv.LLDP_TLV_PORT_ID: + (self.add_single_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 may be multiple Mgmt Address TLVs so store in list + 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_info, 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_info, 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.debug("Organizationally Unique ID %s not " + "recognized", oui) + + +class LLDPdot1Parser(LLDPParser): + """Class to handle parsing of 802.1Q TLVs""" + def __init__(self, node_info, nv=None): + super(LLDPdot1Parser, self).__init__(node_info, 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 + 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_info, nv=None): + super(LLDPdot3Parser, self).__init__(node_info, 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_inspector/common/lldp_tlvs.py b/ironic_inspector/common/lldp_tlvs.py new file mode 100644 index 000000000..9b85861b5 --- /dev/null +++ b/ironic_inspector/common/lldp_tlvs.py @@ -0,0 +1,366 @@ +# 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 """ + +import functools + +# See http://construct.readthedocs.io/en/latest/index.html +import construct +from construct import core +import netaddr + +from ironic_inspector import utils + +LOG = utils.getProcessingLogger(__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.Embedded(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.Embedded(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.Embedded(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.String(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_inspector/plugins/lldp_basic.py b/ironic_inspector/plugins/lldp_basic.py new file mode 100644 index 000000000..f75a2facd --- /dev/null +++ b/ironic_inspector/plugins/lldp_basic.py @@ -0,0 +1,88 @@ +# 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 ironic_inspector.common.i18n import _LW +from ironic_inspector.common import lldp_parsers +from ironic_inspector.plugins import base +from ironic_inspector import utils + +LOG = utils.getProcessingLogger(__name__) + + +class LLDPBasicProcessingHook(base.ProcessingHook): + """Process mandatory and optional LLDP packet fields + + Loop through raw LLDP TLVs and parse those from the + basic management, 802.1, and 802.3 TLV sets. + Store parsed data back to the ironic-inspector database. + """ + + def _parse_lldp_tlvs(self, tlvs, node_info): + """Parse LLDP TLVs into dictionary of name/value pairs + + :param tlvs: list of raw TLVs + :param node_info: node being introspected + :returns nv: 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_info) + + for tlv_type, tlv_value in tlvs: + try: + data = bytearray(binascii.a2b_hex(tlv_value)) + except TypeError as e: + LOG.warning(_LW( + "TLV value for TLV type %(tlv_type)d not in correct " + "format, value must be in hexadecimal: %(msg)s"), + {'tlv_type': tlv_type, 'msg': e}) + continue + + if parser.parse_tlv(tlv_type, data): + LOG.debug("Handled TLV type %d", + tlv_type, node_info=node_info) + else: + LOG.debug("LLDP TLV type %d not handled", + tlv_type, node_info=node_info) + + return parser.nv_dict + + def before_update(self, introspection_data, node_info, **kwargs): + """Process LLDP data and update all_interfaces with processed data""" + + inventory = utils.get_inventory(introspection_data) + + for iface in inventory['interfaces']: + if_name = iface['name'] + + tlvs = iface.get('lldp') + if tlvs is None: + LOG.warning(_LW("No LLDP Data found for interface %s"), + if_name, node_info=node_info) + continue + + LOG.debug("Processing LLDP Data for interface %s", + if_name, node_info=node_info) + + nv = self._parse_lldp_tlvs(tlvs, node_info) + + if nv: + # Store lldp data per interface in "all_interfaces" + iface_to_update = introspection_data['all_interfaces'][if_name] + iface_to_update['lldp_processed'] = nv diff --git a/ironic_inspector/test/base.py b/ironic_inspector/test/base.py index 11dabdd35..d7bb4e6ec 100644 --- a/ironic_inspector/test/base.py +++ b/ironic_inspector/test/base.py @@ -100,7 +100,11 @@ class InventoryTest(BaseTest): 'inventory': { 'interfaces': [ {'name': 'eth1', 'mac_address': self.macs[0], - 'ipv4_address': self.ips[0]}, + 'ipv4_address': self.ips[0], + 'lldp': [ + [1, "04112233aabbcc"], + [2, "07373334"], + [3, "003c"]]}, {'name': 'eth2', 'mac_address': self.inactive_mac}, {'name': 'eth3', 'mac_address': self.macs[1], 'ipv4_address': self.ips[1]}, diff --git a/ironic_inspector/test/functional.py b/ironic_inspector/test/functional.py index 213a8c3d7..9611137b0 100644 --- a/ironic_inspector/test/functional.py +++ b/ironic_inspector/test/functional.py @@ -60,6 +60,8 @@ debug = True auth_strategy = noauth [database] connection = sqlite:///%(db_file)s +[processing] +processing_hooks=$default_processing_hooks,lldp_basic """ @@ -713,6 +715,41 @@ class Test(Base): status = self.call_get_status(self.uuid) self.check_status(status, finished=True) + @mock.patch.object(swift, 'store_introspection_data', autospec=True) + @mock.patch.object(swift, 'get_introspection_data', autospec=True) + def test_lldp_plugin(self, get_mock, store_mock): + cfg.CONF.set_override('store_data', 'swift', 'processing') + + ramdisk_data = json.dumps(copy.deepcopy(self.data)) + get_mock.return_value = ramdisk_data + + self.call_introspect(self.uuid) + eventlet.greenthread.sleep(DEFAULT_SLEEP) + self.cli.node.set_power_state.assert_called_once_with(self.uuid, + 'reboot') + + status = self.call_get_status(self.uuid) + self.check_status(status, finished=False) + + res = self.call_continue(self.data) + self.assertEqual({'uuid': self.uuid}, res) + eventlet.greenthread.sleep(DEFAULT_SLEEP) + + status = self.call_get_status(self.uuid) + self.check_status(status, finished=True) + + # Verify that the lldp_processed data is written to swift + # as expected by the lldp plugin + updated_data = store_mock.call_args[0][0] + lldp_out = updated_data['all_interfaces']['eth1'] + + expected_chassis_id = "11:22:33:aa:bb:cc" + expected_port_id = "734" + self.assertEqual(expected_chassis_id, + lldp_out['lldp_processed']['switch_chassis_id']) + self.assertEqual(expected_port_id, + lldp_out['lldp_processed']['switch_port_id']) + @contextlib.contextmanager def mocked_server(): diff --git a/ironic_inspector/test/unit/test_plugins_lldp_basic.py b/ironic_inspector/test/unit/test_plugins_lldp_basic.py new file mode 100644 index 000000000..ce58932f8 --- /dev/null +++ b/ironic_inspector/test/unit/test_plugins_lldp_basic.py @@ -0,0 +1,329 @@ +# 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.common import lldp_parsers as nv +from ironic_inspector.plugins import lldp_basic +from ironic_inspector.test import base as test_base + + +class TestLLDPBasicProcessingHook(test_base.NodeTest): + hook = lldp_basic.LLDPBasicProcessingHook() + + def setUp(self): + super(TestLLDPBasicProcessingHook, self).setUp() + self.data = { + 'inventory': { + 'interfaces': [{ + 'name': 'em1', + }], + 'cpu': 1, + 'disks': 1, + 'memory': 1 + }, + 'all_interfaces': + { + 'em1': {'mac': self.macs[0], 'ip': self.ips[0]} + } + } + + self.expected = {"em1": {"ip": self.ips[0], "mac": self.macs[0]}} + + def test_all_valid_data(self): + + self.data['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_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' + } + + self.hook.before_update(self.data, self.node_info) + + actual_all_int = self.data['all_interfaces'] + actual = actual_all_int['em1']['lldp_processed'] + + 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.data = { + 'inventory': { + 'interfaces': [ + {'name': 'em1', + 'lldp': [ + [1, "04112233aabbcc"], + [2, "07373334"], + [3, "003c"]]}, + {'name': 'em2', + 'lldp': [ + [1, "04112233aabbdd"], + [2, "07373838"], + [3, "003c"]]}, + {'name': 'em3', + 'lldp': [ + [1, "04112233aabbee"], + [2, "07373939"], + [3, "003c"]]}], + 'cpu': 1, + 'disks': 1, + 'memory': 1 + }, + 'all_interfaces': + { + 'em1': {'mac': self.macs[0], 'ip': self.ips[0]}, + 'em2': {'mac': self.macs[0], 'ip': self.ips[0]}, + 'em3': {'mac': self.macs[0], 'ip': self.ips[0]} + } + } + + expected = {"em1": {"ip": self.ips[0], "mac": self.macs[0], + "lldp_processed": { + nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:cc", + nv.LLDP_PORT_ID_NM: "734"}}, + "em2": {"ip": self.ips[0], "mac": self.macs[0], + "lldp_processed": { + nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:dd", + nv.LLDP_PORT_ID_NM: "788"}}, + "em3": {"ip": self.ips[0], "mac": self.macs[0], + "lldp_processed": { + nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:ee", + nv.LLDP_PORT_ID_NM: "799"}}} + + self.hook.before_update(self.data, self.node_info) + self.assertEqual(expected, self.data['all_interfaces']) + + def test_chassis_ids(self): + # Test IPv4 address + self.data['inventory']['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [1, "0501c000020f"], + ]}] + + self.expected['em1']['lldp_processed'] = { + nv.LLDP_CHASSIS_ID_NM: "192.0.2.15" + } + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + + # Test name + self.data['inventory']['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [1, "0773773031"], + ]}] + + self.expected['em1']['lldp_processed'] = { + nv.LLDP_CHASSIS_ID_NM: "sw01" + } + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + + def test_duplicate_tlvs(self): + self.data['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']['lldp_processed'] = { + nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:cc", + nv.LLDP_PORT_ID_NM: "734" + } + + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + + def test_unhandled_tlvs(self): + self.data['inventory']['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [10, "04112233aabbcc"], + [12, "07373334"], + [128, "00120f080300010000"]]}] + + # nothing should be written to lldp_processed + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + + def test_unhandled_oui(self): + self.data['inventory']['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [127, "00906901425030323134323530393236"], + [127, "23ac0074657374"], + [127, "00120e010300010000"]]}] + + # nothing should be written to lldp_processed + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + + @mock.patch('ironic_inspector.common.lldp_parsers.LOG') + def test_null_strings(self, mock_log): + self.data['inventory']['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [1, "04"], + [4, ""], # PortDesc + [5, ""], # SysName + [6, ""], # SysDesc + [127, "0080c203006507"] # dot1 VlanName + ]}] + + self.expected['em1']['lldp_processed'] = { + nv.LLDP_PORT_DESC_NM: '', + nv.LLDP_SYS_DESC_NM: '', + nv.LLDP_SYS_NAME_NM: '' + } + + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + self.assertEqual(2, mock_log.warning.call_count) + + @mock.patch('ironic_inspector.common.lldp_parsers.LOG') + def test_truncated_int(self, mock_log): + self.data['inventory']['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [127, "00120f04"], # dot3 MTU + [127, "0080c201"], # dot1 PortVlan + [127, "0080c206"], # dot1 MgmtVID + ]}] + + # nothing should be written to lldp_processed + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + self.assertEqual(3, mock_log.warning.call_count) + + @mock.patch('ironic_inspector.common.lldp_parsers.LOG') + def test_invalid_ip(self, mock_log): + self.data['inventory']['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [8, "0501"], # truncated + [8, "0507c000020f020000000000"]] # invalid id + }] + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + self.assertEqual(2, mock_log.warning.call_count) + + @mock.patch('ironic_inspector.common.lldp_parsers.LOG') + def test_truncated_mac(self, mock_log): + self.data['inventory']['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [8, "0506"]] + }] + + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + self.assertEqual(1, mock_log.warning.call_count) + + @mock.patch('ironic_inspector.common.lldp_parsers.LOG') + def test_bad_value_macphy(self, mock_log): + self.data['inventory']['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [127, "00120f01036c11FFFF"], # invalid mau type + [127, "00120f01036c11"], # truncated + [127, "00120f01036c"] # truncated + ]}] + + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + self.assertEqual(3, mock_log.warning.call_count) + + @mock.patch('ironic_inspector.common.lldp_parsers.LOG') + def test_bad_value_linkagg(self, mock_log): + self.data['inventory']['interfaces'] = [{ + 'name': 'em1', + 'lldp': [ + [127, "00120f0303"], # dot3 LinkAggregation + [127, "00120f03"] # truncated + ]}] + + self.hook.before_update(self.data, self.node_info) + self.assertEqual(self.expected, self.data['all_interfaces']) + self.assertEqual(2, mock_log.warning.call_count) diff --git a/releasenotes/notes/add-lldp-basic-plugin-98aebcf43e60931b.yaml b/releasenotes/notes/add-lldp-basic-plugin-98aebcf43e60931b.yaml new file mode 100644 index 000000000..cb88d45ef --- /dev/null +++ b/releasenotes/notes/add-lldp-basic-plugin-98aebcf43e60931b.yaml @@ -0,0 +1,4 @@ +--- +features: + - Added a plugin to parse raw LLDP Basic Management, 802.1, and + 802.3 TLVs and store the data back in Swift. diff --git a/requirements.txt b/requirements.txt index 921a6daff..938790ff5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ automaton>=0.5.0 # Apache-2.0 alembic>=0.8.10 # MIT Babel>=2.3.4 # BSD +construct>=2.8.10 # MIT eventlet!=0.18.3,>=0.18.2 # MIT Flask!=0.11,<1.0,>=0.10 # BSD futurist!=0.15.0,>=0.11.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index ebdd6af35..91437ef90 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,6 +35,7 @@ ironic_inspector.hooks.processing = raid_device = ironic_inspector.plugins.raid_device:RaidDeviceDetection capabilities = ironic_inspector.plugins.capabilities:CapabilitiesHook 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 ironic_inspector.hooks.node_not_found = example = ironic_inspector.plugins.example:example_not_found_hook