From fe3c1a1410bbc2ce384908cd99b0382a88261da8 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Wed, 5 Aug 2026 00:45:15 +1000 Subject: [PATCH] feat: add NetBox IP/interface facts with offline cache Add a structured `netbox` fact that reads this node's IP/interface data from NetBox, and profiles::netbox::facts to seed its credentials. - netbox fact: queries NetBox (devices + VMs) by fqdn/hostname, emits interfaces (name, mac, ips, primary) and primary_ip. - Caches every success to /var/cache/puppet-netbox/facts.json (0600). On any failure (short timeouts, DNS, non-200, parse) it serves the cached payload with cached=true; static IPs never change so stale is always safe. A never-cached host returns nothing; the fact never raises. - profiles::netbox::facts: inert until $api_token is set; writes the root-only token/url files and the cache dir. Confined off (no-op) on hosts without the token. Included from profiles::base. Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT --- modules/libs/lib/facter/netbox.rb | 130 ++++++++++++++++++++++++ site/profiles/manifests/base.pp | 1 + site/profiles/manifests/netbox/facts.pp | 48 +++++++++ 3 files changed, 179 insertions(+) create mode 100644 modules/libs/lib/facter/netbox.rb create mode 100644 site/profiles/manifests/netbox/facts.pp diff --git a/modules/libs/lib/facter/netbox.rb b/modules/libs/lib/facter/netbox.rb new file mode 100644 index 0000000..293d980 --- /dev/null +++ b/modules/libs/lib/facter/netbox.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'facter' +require 'net/http' +require 'uri' +require 'json' +require 'fileutils' +require 'time' + +# Serves this node's IP/interface data from NetBox as the structured `netbox` +# fact. NetBox is authoritative and static IPs never change, so every +# successful response is cached forever and reused verbatim whenever NetBox is +# unreachable: an outage serves stale-but-correct data and can never fail a +# puppet run. Only a host that was never cached returns nothing. +module NetboxFacts + TOKEN_FILE = '/etc/puppetlabs/netbox.token' + URL_FILE = '/etc/puppetlabs/netbox.url' + CACHE_FILE = '/var/cache/puppet-netbox/facts.json' + DEFAULT_URL = 'https://netbox.k8s.syd1.au.unkin.net' + + # Talks to the NetBox REST API for one node and assembles the fact payload. + class Client + OPEN_TIMEOUT = 3 + READ_TIMEOUT = 5 + PAGE = 500 + + def initialize + @token = File.read(TOKEN_FILE).strip + @base = File.exist?(URL_FILE) ? File.read(URL_FILE).strip : DEFAULT_URL + @names = [Facter.value(:fqdn), Facter.value(:hostname)].compact.uniq + end + + # Live payload, or nil when the node is absent from NetBox. Raises on any + # transport/parse error so the caller can fall back to cache. + def fetch + device, kind, filter, iface_base = locate + return nil unless device + + primary = device.dig('primary_ip', 'address') + { + 'source' => kind, 'name' => device['name'], 'primary_ip' => primary, + 'interfaces' => interfaces(iface_base, filter, primary), + 'fetched_at' => Time.now.utc.iso8601, 'cached' => false + } + end + + private + + def locate + device = results("/api/dcim/devices/?#{name_query}").first + return [device, 'device', "device_id=#{device['id']}", '/api/dcim/interfaces/'] if device + + vm = results("/api/virtualization/virtual-machines/?#{name_query}").first + vm && [vm, 'vm', "virtual_machine_id=#{vm['id']}", '/api/virtualization/interfaces/'] + end + + def name_query + @names.map { |name| "name=#{URI.encode_www_form_component(name)}" }.join('&') + end + + def interfaces(iface_base, filter, primary) + ips = ips_by_interface(filter) + results("#{iface_base}?#{filter}&limit=#{PAGE}").map do |iface| + addrs = ips[iface['id']] || [] + mac = iface['mac_address'] || iface.dig('primary_mac_address', 'mac_address') + { 'name' => iface['name'], 'mac' => mac, 'ips' => addrs, 'primary' => addrs.include?(primary) } + end + end + + def ips_by_interface(filter) + results("/api/ipam/ip-addresses/?#{filter}&limit=#{PAGE}").each_with_object({}) do |ip, map| + (map[ip['assigned_object_id']] ||= []) << ip['address'] + end + end + + def results(path) + JSON.parse(get(URI("#{@base}#{path}")).body).fetch('results', []) + end + + def get(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.open_timeout = OPEN_TIMEOUT + http.read_timeout = READ_TIMEOUT + response = http.request(request_for(uri)) + return response if response.is_a?(Net::HTTPSuccess) + + raise "netbox #{uri.path} -> HTTP #{response.code}" + end + + def request_for(uri) + request = Net::HTTP::Get.new(uri) + request['Authorization'] = "Token #{@token}" + request['Accept'] = 'application/json' + request + end + end + + module_function + + def write_cache(data) + FileUtils.mkdir_p(File.dirname(CACHE_FILE)) + File.write(CACHE_FILE, JSON.pretty_generate(data)) + File.chmod(0o600, CACHE_FILE) + end + + def read_cache + return nil unless File.exist?(CACHE_FILE) + + JSON.parse(File.read(CACHE_FILE)).merge('cached' => true) + rescue StandardError + nil + end + + def resolve + data = Client.new.fetch + return read_cache unless data + + write_cache(data) + data + rescue StandardError => e + Facter.warn("netbox fact: live fetch failed (#{e.message}); serving cache") + read_cache + end +end + +Facter.add(:netbox) do + confine { File.exist?(NetboxFacts::TOKEN_FILE) } + setcode { NetboxFacts.resolve } +end diff --git a/site/profiles/manifests/base.pp b/site/profiles/manifests/base.pp index cc1cab5..f9d4465 100644 --- a/site/profiles/manifests/base.pp +++ b/site/profiles/manifests/base.pp @@ -35,6 +35,7 @@ class profiles::base () { include profiles::ssh::service include profiles::cloudinit::init include profiles::helpers::node_lookup + include profiles::netbox::facts include profiles::consul::client include victorialogs::client::journald diff --git a/site/profiles/manifests/netbox/facts.pp b/site/profiles/manifests/netbox/facts.pp new file mode 100644 index 0000000..c8566be --- /dev/null +++ b/site/profiles/manifests/netbox/facts.pp @@ -0,0 +1,48 @@ +# profiles::netbox::facts +# +# Seeds the credentials the `netbox` custom fact needs to read this node's +# IP/interface data from NetBox. Inert until $api_token is set: with no token +# the fact is confined off (unenrolled hosts no-op). +# +# NetBox is authoritative and static IPs never change, so the fact caches every +# successful response under $cache_dir forever and reuses it during a NetBox +# outage - an outage can never fail a puppet run. +class profiles::netbox::facts ( + Optional[Sensitive[String]] $api_token = undef, + Stdlib::HTTPSUrl $url = 'https://netbox.k8s.syd1.au.unkin.net', + Stdlib::AbsolutePath $token_file = '/etc/puppetlabs/netbox.token', + Stdlib::AbsolutePath $url_file = '/etc/puppetlabs/netbox.url', + Stdlib::AbsolutePath $cache_dir = '/var/cache/puppet-netbox', +) { + + if $api_token =~ Undef { + notify { 'netbox-facts-inert': + message => 'profiles::netbox::facts: api_token unset; netbox fact disabled on this host.', + loglevel => 'info', + } + } else { + file { $token_file: + ensure => file, + owner => 'root', + group => 'root', + mode => '0600', + show_diff => false, + content => Sensitive("${api_token.unwrap}\n"), + } + + file { $url_file: + ensure => file, + owner => 'root', + group => 'root', + mode => '0644', + content => "${url}\n", + } + + file { $cache_dir: + ensure => directory, + owner => 'root', + group => 'root', + mode => '0700', + } + } +} -- 2.47.3