# 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