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: unkin/terraform-infra#7 (extend the pdbmux backfill / PR #6 to read this fact and emit the connection).

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #513
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
This commit was merged in pull request #513.
This commit is contained in:
2026-08-08 18:41:50 +10:00
committed by BenVincent
parent 10854b6501
commit 499251575d
3 changed files with 142 additions and 0 deletions
+5
View File
@@ -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')}": {}
+104
View File
@@ -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
+33
View File
@@ -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,
}
}
}