585d32b15c
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
272 lines
6.9 KiB
Go
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
|
|
}
|