Backfill NetBox reality from pdbmux
ci/woodpecker/pr/plan Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/backfill-test Pipeline was successful

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
This commit is contained in:
2026-08-06 23:15:21 +10:00
parent 9a6f775300
commit 585d32b15c
30 changed files with 2504 additions and 122 deletions
+3
View File
@@ -54,6 +54,9 @@ resource "netbox_device" "this" {
role_id = netbox_device_role.this[each.value.role].id
site_id = tonumber(data.netbox_site.this[each.value.site].id)
status = each.value.pxe ? "staged" : "active"
# Serial is hardware reality; take it from the backfill when present.
serial = try(var.reality[each.key].serial, null)
}
# Transitional: bootapi keys PXE on MAC and no discovery image exists yet, so seed a
+100
View File
@@ -0,0 +1,100 @@
locals {
# Reality is wired only for hosts that also carry an intent device (the 13
# existing physicals get their intent seeded in issue #2). Until then a host's
# reality/*.yaml is reviewed and committed but produces no NetBox resources.
reality_devices = { for k, r in var.reality : k => r if contains(keys(var.devices), k) }
# "<device>:<iface>" -> real interface. Names come from Facter, never assumed.
reality_interfaces = merge([
for dname, r in local.reality_devices : {
for i in coalesce(r.interfaces, []) : "${dname}:${i.name}" => {
device = dname
name = i.name
mac = i.mac
mtu = i.mtu
physical = coalesce(i.physical, false)
}
}
]...)
reality_macs = { for k, i in local.reality_interfaces : k => i if i.mac != null && i.mac != "" }
# "<device>:<iface>:<ip>" -> address on that interface (CIDR form).
reality_ips = merge([
for dname, r in local.reality_devices : {
for pair in flatten([
for i in coalesce(r.interfaces, []) : [
for ip in coalesce(i.ips, []) : { device = dname, iface = i.name, ip = ip }
]
]) : "${pair.device}:${pair.iface}:${pair.ip}" => pair
}
]...)
# "<device>:<key>" -> one NetBox inventory item (CPU, memory, per-disk).
reality_inventory = merge([
for dname, r in local.reality_devices : {
for item in concat(
try(r.inventory.cpu, null) == null ? [] : [{
key = "cpu"
name = "CPU"
description = trimspace(format("%s x%d", try(r.inventory.cpu.model, ""), try(r.inventory.cpu.count, 0)))
part_id = try(r.inventory.cpu.model, null)
serial = null
}],
try(r.inventory.memory_bytes, null) == null ? [] : [{
key = "memory"
name = "Memory"
description = format("%d bytes", r.inventory.memory_bytes)
part_id = null
serial = null
}],
[for d in try(r.inventory.disks, []) : {
key = "disk-${d.name}"
name = "Disk ${d.name}"
description = trimspace(format("%s %d bytes", try(d.model, ""), try(d.size_bytes, 0)))
part_id = try(d.model, null)
serial = try(d.serial, null)
}]
) : "${dname}:${item.key}" => merge(item, { device = dname })
}
]...)
}
# Real NICs and virtual interfaces (overlay/loopback/kube-lb) the host reports.
resource "netbox_device_interface" "reality" {
for_each = local.reality_interfaces
device_id = netbox_device.this[each.value.device].id
name = each.value.name
type = each.value.physical ? "1000base-t" : "virtual"
mtu = each.value.mtu
}
resource "netbox_mac_address" "reality" {
for_each = local.reality_macs
mac_address = each.value.mac
device_interface_id = netbox_device_interface.reality[each.key].id
}
# NetBox stays IP-authoritative; these record the addresses (incl. overlay /
# loopback / kube-lb VIPs) a running host actually carries.
resource "netbox_ip_address" "reality" {
for_each = local.reality_ips
ip_address = each.value.ip
status = "active"
description = each.value.device
device_interface_id = netbox_device_interface.reality["${each.value.device}:${each.value.iface}"].id
}
resource "netbox_inventory_item" "reality" {
for_each = local.reality_inventory
device_id = netbox_device.this[each.value.device].id
name = each.value.name
description = each.value.description
part_id = each.value.part_id
serial = each.value.serial
discovered = true
}
+40
View File
@@ -66,6 +66,46 @@ variable "managed_ips" {
default = []
}
variable "reality" {
description = <<-EOT
Per-host hardware reality keyed by device name, generated from pdbmux by
tools/backfill (see `make backfill`) — never hand-authored. It carries the
facts NetBox cannot learn from intent: serial/model/UUID, every recordable
interface (real NICs plus overlay/loopback/kube-lb) with MAC and CIDR IPs,
and CPU/RAM/disk inventory. Reality is wired into NetBox only for hosts that
also have an intent `devices/<host>.yaml`; until that intent lands, a host's
reality sits reviewed-but-inert.
EOT
type = map(object({
device = optional(string)
serial = optional(string)
model = optional(string)
uuid = optional(string)
interfaces = optional(list(object({
name = string
mac = optional(string)
mtu = optional(number)
physical = optional(bool, false)
ips = optional(list(string), [])
})), [])
inventory = optional(object({
cpu = optional(object({
model = optional(string)
cores = optional(number)
count = optional(number)
}))
memory_bytes = optional(number)
disks = optional(list(object({
name = string
model = optional(string)
serial = optional(string)
size_bytes = optional(number)
})), [])
}))
}))
default = {}
}
variable "vault_address" {
description = "Vault server address for the token data source."
type = string