eb87d4b49b
ci/woodpecker/pr/ruby-validate Pipeline was successful
ci/woodpecker/pr/puppet-lint Pipeline was successful
ci/woodpecker/pr/yamllint Pipeline was successful
ci/woodpecker/pr/bolt-validate Pipeline was successful
ci/woodpecker/pr/erb-validate Pipeline was successful
ci/woodpecker/pr/epp-validate Pipeline was successful
ci/woodpecker/pr/ruby-check Pipeline was successful
ci/woodpecker/pr/puppet-validate Pipeline was successful
LLDP is the only source of physical switch/port topology in the estate; NetBox has no other way to learn which switch and port each machine is cabled to. Run lldpd on physical hosts and surface its neighbour data so the terraform-infra pdbmux backfill can populate NetBox interface connections. - add profiles::lldpd installing the lldpd package and enabling+starting the service (it ships disabled), the service subscribing to the package - assign it physicals-only via hiera_include in hieradata/virtual/physical.yaml (facts.virtual == 'physical'), merged unique with the common hiera_include; VMs never get it - add the `lldp` custom fact (modules/libs/lib/facter/lldp.rb) parsing `lldpctl -f json0` into a per-interface map of neighbour chassis/port/vlan, skipping interfaces with no neighbour; confined to physical Linux hosts with lldpctl and a live lldpd socket, and never raising (empty hash on any error) Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
105 lines
3.1 KiB
Ruby
105 lines
3.1 KiB
Ruby
# 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
|