# frozen_string_literal: true require 'facter' require 'json' # Exposes LLDP neighbour topology (switch/port each interface is cabled to) as # the structured `lldp` fact, keyed by local interface. This is the only source # of physical switch/port topology in the estate and feeds NetBox. Uses # `lldpctl -f json0`: json0 wraps every node in an array regardless of # cardinality, so one neighbour and many neighbours parse identically (plain # `keyvalue` folds the neighbour's sysname into the key path, and plain `json` # collapses single-element arrays into objects). Never raises: any error or a # down daemon yields an empty hash so a puppet run can never break. module LldpFact SOCKETS = ['/run/lldpd.socket', '/var/run/lldpd.socket'].freeze module_function # First element of a json0 node (everything is array-wrapped), or the value # itself if it is not an array; nil when absent. def first(node) node.is_a?(Array) ? node[0] : node end # Array form of a json0 node whatever its cardinality. def list(node) node.is_a?(Array) ? node : [node].compact end # Value string of a json0 leaf like [{ 'value' => 'x' }]. def leaf(node) entry = first(node) entry.is_a?(Hash) ? entry['value'] : entry end # Chassis MAC from its id list, preferring the entry typed 'mac'. def chassis_mac(chassis) ids = list(chassis['id']) mac = ids.find { |id| id.is_a?(Hash) && id['type'] == 'mac' } || ids.first mac.is_a?(Hash) ? mac['value'] : nil end # Topology record for one local interface, or nil when it has no neighbour. def neighbour(iface) chassis = first(iface['chassis']) port = first(iface['port']) return nil unless chassis && port chassis_fields(chassis).merge(port_fields(port, first(iface['vlan']))) end def chassis_fields(chassis) { 'neighbor_chassis_name' => leaf(chassis['name']), 'neighbor_chassis_mac' => chassis_mac(chassis), 'neighbor_chassis_descr' => leaf(chassis['descr']) } end def port_fields(port, vlan) port_id = first(port['id']) vlan_h = vlan.is_a?(Hash) ? vlan : {} { 'neighbor_port_id' => port_id.is_a?(Hash) ? port_id['value'] : port_id, 'neighbor_port_descr' => leaf(port['descr']), 'vlan_id' => vlan_h['vlan-id'], 'vlan_name' => vlan_h['value'] } end def interfaces(output) lldp = first(JSON.parse(output)['lldp']) || {} list(lldp['interface']) end # Map of local interface => topology record, skipping interfaces with no # neighbour. def collect(ifaces) ifaces.each_with_object({}) do |iface, acc| next unless iface.is_a?(Hash) name = iface['name'] data = neighbour(iface) acc[name] = data if name && data end end def resolve output = Facter::Core::Execution.execute('lldpctl -f json0 2>/dev/null', on_fail: nil) return {} if output.to_s.empty? collect(interfaces(output)) rescue StandardError {} end end Facter.add(:lldp) do confine kernel: 'Linux' confine { Facter.value(:is_virtual) == false } confine { Facter::Core::Execution.which('lldpctl') } confine { LldpFact::SOCKETS.any? { |path| File.exist?(path) } } setcode { LldpFact.resolve } end