feat: add NetBox IP/interface facts with offline cache
ci/woodpecker/pr/ruby-validate Pipeline was successful
ci/woodpecker/pr/puppet-lint Pipeline was successful
ci/woodpecker/pr/yamllint Pipeline was successful
ci/woodpecker/pr/bolt-validate Pipeline was successful
ci/woodpecker/pr/erb-validate Pipeline was successful
ci/woodpecker/pr/epp-validate Pipeline was successful
ci/woodpecker/pr/puppet-validate Pipeline was successful
ci/woodpecker/pr/ruby-check Pipeline was successful
ci/woodpecker/pr/ruby-validate Pipeline was successful
ci/woodpecker/pr/puppet-lint Pipeline was successful
ci/woodpecker/pr/yamllint Pipeline was successful
ci/woodpecker/pr/bolt-validate Pipeline was successful
ci/woodpecker/pr/erb-validate Pipeline was successful
ci/woodpecker/pr/epp-validate Pipeline was successful
ci/woodpecker/pr/puppet-validate Pipeline was successful
ci/woodpecker/pr/ruby-check Pipeline was successful
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
This commit is contained in:
@@ -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
|
||||||
@@ -35,6 +35,7 @@ class profiles::base () {
|
|||||||
include profiles::ssh::service
|
include profiles::ssh::service
|
||||||
include profiles::cloudinit::init
|
include profiles::cloudinit::init
|
||||||
include profiles::helpers::node_lookup
|
include profiles::helpers::node_lookup
|
||||||
|
include profiles::netbox::facts
|
||||||
include profiles::consul::client
|
include profiles::consul::client
|
||||||
include victorialogs::client::journald
|
include victorialogs::client::journald
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user