Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Vincent fe3c1a1410 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
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
2026-08-05 00:45:15 +10:00
12 changed files with 184 additions and 83 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
timezone::timezone: 'Australia/Sydney'
certbot::client::webserver: ausyd1nxvm2057.main.unkin.net
profiles_dns_upstream_forwarder_unkin:
- 198.18.200.7
- 198.18.19.15
profiles_dns_upstream_forwarder_consul:
- 198.18.19.14
profiles_dns_upstream_forwarder_k8s:
@@ -1,13 +0,0 @@
---
networking_loopback0_ip: 198.18.19.21 # management loopback
networking_loopback1_ip: 198.18.22.14 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.14 # ceph-public loopback
networking_1000_ip: 198.18.15.14 # 1gbe network
networking_2500_ip: 198.18.21.14 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: a4:bb:6d:a4:e5:c1
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:50:03
@@ -1,13 +0,0 @@
---
networking_loopback0_ip: 198.18.19.22 # management loopback
networking_loopback1_ip: 198.18.22.15 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.15 # ceph-public loopback
networking_1000_ip: 198.18.15.15 # 1gbe network
networking_2500_ip: 198.18.21.15 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: a4:bb:6d:a6:30:c4
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:4f:f4
@@ -1,13 +0,0 @@
---
networking_loopback0_ip: 198.18.19.23 # management loopback
networking_loopback1_ip: 198.18.22.16 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.16 # ceph-public loopback
networking_1000_ip: 198.18.15.16 # 1gbe network
networking_2500_ip: 198.18.21.16 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: a4:bb:6d:9f:22:13
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:50:0c
@@ -1,13 +0,0 @@
---
networking_loopback0_ip: 198.18.19.24 # management loopback
networking_loopback1_ip: 198.18.22.17 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.17 # ceph-public loopback
networking_1000_ip: 198.18.15.17 # 1gbe network
networking_2500_ip: 198.18.21.17 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: 8c:04:ba:9c:b6:08
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:50:12
@@ -1,13 +0,0 @@
---
networking_loopback0_ip: 198.18.19.25 # management loopback
networking_loopback1_ip: 198.18.22.18 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.18 # ceph-public loopback
networking_1000_ip: 198.18.15.18 # 1gbe network
networking_2500_ip: 198.18.21.18 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: a4:bb:6d:a4:db:94
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:4f:fa
@@ -1,13 +0,0 @@
---
networking_loopback0_ip: 198.18.19.26 # management loopback
networking_loopback1_ip: 198.18.22.19 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.19 # ceph-public loopback
networking_1000_ip: 198.18.15.19 # 1gbe network
networking_2500_ip: 198.18.21.19 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: a4:bb:6d:a4:56:11
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:50:00
+130
View File
@@ -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
+2 -2
View File
@@ -27,10 +27,10 @@ class rke2::install (
before => Service["rke2-${node_type}"],
}
# preload the airgap bundle (has the default canal CNI images) so canal starts from disk, not the mirror VIP that needs flannel first
# download required archive of containers
archive { '/var/lib/rancher/rke2/agent/images/rke2-images.linux-amd64.tar.zst':
ensure => present,
source => "${container_archive_source}/v${rke2_version}%2B${rke2_release}/rke2-images.linux-amd64.tar.zst",
source => "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github/rancher/rke2/releases/download/v${rke2_version}%2B${rke2_release}/rke2-images.linux-amd64.tar.zst",
require => [
Package["rke2-${node_type}"],
File['/var/lib/rancher/rke2/agent/images'],
+2 -2
View File
@@ -1,7 +1,7 @@
# rke2 params
class rke2::params (
Enum['server', 'agent'] $node_type = 'agent',
String $rke2_version = '1.33.11',
String $rke2_version = '1.33.4',
String $rke2_release = 'rke2r1',
Stdlib::Absolutepath $config_file = '/etc/rancher/rke2/config.yaml',
Hash $config_hash = {},
@@ -11,7 +11,7 @@ class rke2::params (
Boolean $helm_install = false,
Hash $helm_repos = {},
Array[String[1]] $extra_config_files = [],
Stdlib::HTTPUrl $container_archive_source = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github/rancher/rke2/releases/download',
Stdlib::HTTPUrl $container_archive_source = 'https://github.com/rancher/rke2/releases/download',
Boolean $manage_registries = false,
Hash $registries = {},
) {}
+1
View File
@@ -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
+48
View File
@@ -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',
}
}
}