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, + } + } +}