From 499251575d1b2a27c4b32c07835dcc7c216030bc Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sat, 8 Aug 2026 18:41:50 +1000 Subject: [PATCH] lldpd: run on physicals and expose neighbour topology as the lldp fact (#513) ## Why LLDP is the only source of physical switch/port topology in the estate: which switch and which port each machine is cabled to exists nowhere else (not in intent YAML, not in any other fact, not in DHCP/DNS). NetBox needs it to record device interface -> switch/port connections. This installs and enables lldpd on physical hosts and exposes its neighbour data as a structured `lldp` fact for the terraform-infra pdbmux backfill to consume (linked issue below). ## Changes - Add `profiles::lldpd`: install the `lldpd` package and enable+start 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 receive it. - Add the `lldp` custom fact (`modules/libs/lib/facter/lldp.rb`): parse `lldpctl -f json0` into a per-interface map of `{neighbor_chassis_name, neighbor_chassis_mac, neighbor_chassis_descr, neighbor_port_id, neighbor_port_descr, vlan_id, vlan_name}`, skipping interfaces with no neighbour. Confined to physical Linux hosts that have `lldpctl` and a live lldpd socket; returns an empty hash on any error so it can never break a puppet run. json0 is used because it array-wraps every node regardless of cardinality, so one neighbour and many neighbours parse identically (plain `keyvalue` folds the neighbour SysName into the key path; plain `json` collapses single-element arrays into objects). ## Sample fact output (prodnxsr0019, enp1s0) ```json { "enp1s0": { "neighbor_chassis_name": "sg3429x-m2-02", "neighbor_chassis_mac": "b8:fb:b3:a7:f9:5d", "neighbor_chassis_descr": "48-Port Gigabit Smart Managed Pro Switch", "neighbor_port_id": "24", "neighbor_port_descr": "two-gigabitEthernet 1/0/11", "vlan_id": "201", "vlan_name": "fabric_common_02" } } ``` ## Consumer NetBox population of the switch/port cabling is tracked in terraform-infra: https://git.unkin.net/unkin/terraform-infra/issues/7 (extend the pdbmux backfill / PR #6 to read this fact and emit the connection). https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT Reviewed-on: https://git.unkin.net/unkin/puppet-prod/pulls/513 Co-authored-by: Ben Vincent Co-committed-by: Ben Vincent --- hieradata/virtual/physical.yaml | 5 ++ modules/libs/lib/facter/lldp.rb | 104 +++++++++++++++++++++++++++++++ site/profiles/manifests/lldpd.pp | 33 ++++++++++ 3 files changed, 142 insertions(+) create mode 100644 modules/libs/lib/facter/lldp.rb create mode 100644 site/profiles/manifests/lldpd.pp diff --git a/hieradata/virtual/physical.yaml b/hieradata/virtual/physical.yaml index edee18f..d021242 100644 --- a/hieradata/virtual/physical.yaml +++ b/hieradata/virtual/physical.yaml @@ -1,3 +1,8 @@ --- +# physical hosts only (facts.virtual == 'physical'); merged 'unique' with the +# common hiera_include in profiles::base. +hiera_include: + - profiles::lldpd + profiles::packages::include: "%{hiera('lm-sensors::package')}": {} diff --git a/modules/libs/lib/facter/lldp.rb b/modules/libs/lib/facter/lldp.rb new file mode 100644 index 0000000..283630b --- /dev/null +++ b/modules/libs/lib/facter/lldp.rb @@ -0,0 +1,104 @@ +# 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 diff --git a/site/profiles/manifests/lldpd.pp b/site/profiles/manifests/lldpd.pp new file mode 100644 index 0000000..ce025af --- /dev/null +++ b/site/profiles/manifests/lldpd.pp @@ -0,0 +1,33 @@ +# profiles::lldpd +# +# Runs lldpd on physical hosts so each machine learns its switch/port topology +# via LLDP. The `lldp` fact exposes that neighbour data for NetBox. Assigned +# via hiera_include from hieradata/virtual/physical.yaml (physicals only); the +# lldpd.service ships disabled, so it is explicitly enabled and started here. +class profiles::lldpd ( + Boolean $enabled = true, + String $package = 'lldpd', + String $service = 'lldpd', +){ + + if $enabled { + package { $package: + ensure => installed, + } + + service { $service: + ensure => running, + enable => true, + subscribe => Package[$package], + } + } else { + service { $service: + ensure => stopped, + enable => false, + } + + package { $package: + ensure => absent, + } + } +}