Files
terraform-infra/tools/backfill/reality.go
T
unkinben 585d32b15c
ci/woodpecker/pr/plan Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/backfill-test Pipeline was successful
Backfill NetBox reality from pdbmux
Automate the PuppetDB reality import (issue #1) by querying pdbmux — the
PuppetDB multiplexer whose HTTPS gateway is reachable from CI/workstations,
unlike raw PuppetDB — instead of PuppetDB directly, and shaping the result to
the NetBox reality side the devices module reconciles.

- Add tools/backfill (Go): query pdbmux /pdb/query/v4/facts for the 13 existing
  physicals and emit per-host reality YAML — serial/model/UUID, every recordable
  interface (real NICs plus overlay/loopback/kube-lb) with MAC and CIDR IPs, and
  CPU/RAM/disk inventory. Filter ephemeral Calico veths and Ceph RBD volumes;
  take interface names from Facter, never assume them.
- Emit deterministic, idempotent, yamllint-clean output into
  config/au/syd1/reality/<host>.yaml, generated for prodnxsr0001-0013.
- Extend modules/infra with a reality variable and reality.tf creating
  netbox_device_interface/netbox_mac_address/netbox_ip_address/
  netbox_inventory_item and device serial; wire reality only for hosts that also
  have an intent device.
- Load reality/*.yaml in the terragrunt env; add `make backfill`; add a
  go vet/test woodpecker job; drop the in-cluster-only Python script.

Closes #1

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
2026-08-06 23:15:21 +10:00

272 lines
6.9 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net"
"sort"
"strings"
)
// placeholderMAC is the constant MAC the kernel assigns to Calico veths; it is
// never a real hardware address and must not be recorded.
const placeholderMAC = "ee:ee:ee:ee:ee:ee"
// virtualDiskPrefixes are block-device name prefixes that are not physical
// disks (Ceph RBD volumes, loop/device-mapper/optical/ram devices). They come
// and go with workloads, so recording them would make the backfill non-idempotent.
var virtualDiskPrefixes = []string{"rbd", "loop", "dm-", "md", "sr", "zram", "ram", "fd", "nbd", "zd", "dasd"}
// Interface is one NIC's recorded reality.
type Interface struct {
Name string
MAC string
MTU int
Physical bool
IPs []string // CIDR form, e.g. 198.18.15.1/24
}
// CPU, Disk and Inventory carry the hardware inventory NetBox never learns from
// intent.
type CPU struct {
Model string
Cores int
Count int
}
type Disk struct {
Name string
Model string
Serial string
SizeBytes int64
}
type Inventory struct {
CPU *CPU
MemoryBytes int64
Disks []Disk
}
// Reality is the hardware-owned truth a running host reports, destined for the
// NetBox reality side (interfaces + inventory).
type Reality struct {
Device string
Serial string
Model string
UUID string
Interfaces []Interface
Inventory *Inventory
}
// factBinding is one address binding under networking.interfaces.<n>.bindings
// (IPv4) or .bindings6 (IPv6).
type factBinding struct {
Address string `json:"address"`
Netmask string `json:"netmask"`
}
// shortName returns the leading label of a certname (prodnxsr0001.main… ->
// prodnxsr0001), which is the NetBox device name.
func shortName(certname string) string {
if i := strings.IndexByte(certname, '.'); i >= 0 {
return certname[:i]
}
return certname
}
// factsToReality maps a Facter/PuppetDB factset to the reality we record.
func factsToReality(certname string, facts map[string]json.RawMessage) Reality {
r := Reality{Device: shortName(certname)}
var dmi struct {
Product struct {
Name string `json:"name"`
UUID string `json:"uuid"`
Serial string `json:"serial_number"`
} `json:"product"`
}
getFact(facts, "dmi", &dmi)
r.Serial = firstNonEmpty(dmi.Product.Serial, getStringFact(facts, "serialnumber"))
r.Model = firstNonEmpty(dmi.Product.Name, getStringFact(facts, "productname"))
r.UUID = firstNonEmpty(dmi.Product.UUID, getStringFact(facts, "uuid"))
r.Interfaces = interfaces(facts)
r.Inventory = inventory(facts)
return r
}
// interfaces enumerates recordable NICs. A physical NIC is always kept (it is
// real hardware even when down); a virtual interface is kept only when it holds
// a routable address, which naturally drops the ephemeral Calico veths (link-
// local only) and lo (loopback only) while keeping overlay/loopback/kube-lb
// interfaces. Interface names are never assumed — they come straight from Facter.
func interfaces(facts map[string]json.RawMessage) []Interface {
var networking struct {
Interfaces map[string]struct {
MAC string `json:"mac"`
MTU int `json:"mtu"`
Physical bool `json:"physical"`
Bindings []factBinding `json:"bindings"`
Bindings6 []factBinding `json:"bindings6"`
} `json:"interfaces"`
}
getFact(facts, "networking", &networking)
var out []Interface
for name, iface := range networking.Interfaces {
var ips []string
for _, b := range append(append([]factBinding{}, iface.Bindings...), iface.Bindings6...) {
ip := net.ParseIP(b.Address)
if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
continue
}
ips = append(ips, toCIDR(b.Address, b.Netmask))
}
if !iface.Physical && len(ips) == 0 {
continue
}
mac := iface.MAC
if strings.EqualFold(mac, placeholderMAC) {
mac = ""
}
sort.Strings(ips)
out = append(out, Interface{
Name: name,
MAC: mac,
MTU: iface.MTU,
Physical: iface.Physical,
IPs: dedupe(ips),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// inventory collects CPU/RAM/disk reality. Disks with a virtual-device name are
// skipped so the output only reflects physical hardware.
func inventory(facts map[string]json.RawMessage) *Inventory {
var processors struct {
Count int `json:"count"`
Cores int `json:"cores"`
Models []string `json:"models"`
}
var memory struct {
System struct {
TotalBytes int64 `json:"total_bytes"`
} `json:"system"`
}
var disks map[string]struct {
Model string `json:"model"`
Serial string `json:"serial"`
SizeBytes int64 `json:"size_bytes"`
}
getFact(facts, "processors", &processors)
getFact(facts, "memory", &memory)
getFact(facts, "disks", &disks)
inv := &Inventory{MemoryBytes: memory.System.TotalBytes}
if len(processors.Models) > 0 || processors.Count > 0 {
model := ""
if len(processors.Models) > 0 {
model = processors.Models[0]
}
inv.CPU = &CPU{Model: model, Cores: processors.Cores, Count: processors.Count}
}
names := make([]string, 0, len(disks))
for n := range disks {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
if isVirtualDisk(n) {
continue
}
d := disks[n]
inv.Disks = append(inv.Disks, Disk{Name: n, Model: d.Model, Serial: d.Serial, SizeBytes: d.SizeBytes})
}
if inv.CPU == nil && inv.MemoryBytes == 0 && len(inv.Disks) == 0 {
return nil
}
return inv
}
func isVirtualDisk(name string) bool {
for _, p := range virtualDiskPrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// toCIDR renders address + netmask as address/prefixlen. Facter secondary
// bindings can omit the netmask; those default to a host route (/32 or /128).
func toCIDR(addr, netmask string) string {
return fmt.Sprintf("%s/%d", addr, maskToPrefix(addr, netmask))
}
func maskToPrefix(addr, netmask string) int {
v4 := net.ParseIP(addr).To4() != nil
hostBits := func() int {
if v4 {
return 32
}
return 128
}
if netmask == "" {
return hostBits()
}
m := net.ParseIP(netmask)
if m == nil {
return hostBits()
}
var mask net.IPMask
if v4 {
mask = net.IPMask(m.To4())
} else {
mask = net.IPMask(m.To16())
}
ones, bits := mask.Size()
if bits == 0 { // non-canonical mask
return hostBits()
}
return ones
}
func getFact(facts map[string]json.RawMessage, name string, dst any) {
if raw, ok := facts[name]; ok {
_ = json.Unmarshal(raw, dst)
}
}
func getStringFact(facts map[string]json.RawMessage, name string) string {
var s string
getFact(facts, name, &s)
return s
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func dedupe(in []string) []string {
if len(in) == 0 {
return in
}
out := in[:1]
for _, v := range in[1:] {
if v != out[len(out)-1] {
out = append(out, v)
}
}
return out
}