Files
terraform-infra/ci/puppetdb_backfill.py
unkinben 9a6f775300
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed
Rename terraform-ipam -> terraform-infra; add devices + networks + puppetdb backfill
Scope now spans devices and provisioning, not just IPAM.

- rename module/consul-path/role ipam -> infra
- networks config (subnet binding + gateway/dns/search constants); prefixes tagged net:<name>
- intent-only devices module: netbox_device + device_type/role/manufacturer, static or
  next-available IPs (sticky via ignore_changes), transitional bootstrap_mac interface for
  bootapi PXE keying
- seed 6 pending hosts prodnxsr0014-0019 (mgmt IPs .14-.19, optiplex-3070)
- ci/puppetdb_backfill.py: emit NetBox reality (serial/model/uuid/interfaces) for existing hosts

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
2026-08-05 19:41:41 +10:00

115 lines
4.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Backfill NetBox device *reality* from PuppetDB for already-provisioned hosts.
This emits the hardware-owned facts a running host reports (serial, model, UUID,
and every interface's name/MAC/IPs including the overlay/loopback addresses
Cobbler never had) as reviewable per-host YAML. It targets the *reality* side of
NetBox (interfaces + inventory) for the ~13 existing physicals; it is NOT the
intent `config/.../devices/*.yaml` (a human writes those).
PuppetDB needs no auth (TLS terminates at the gateway; upstream is plain HTTP).
It is NOT reachable from CI, so run this in-cluster or from puppet infra:
./puppetdb_backfill.py --out ../reality \
--url http://puppetdb.puppet.svc.cluster.local:8080/pdb/query/v4/facts \
prodnxsr0001 prodnxsr0002 ...
Fact paths follow Facter conventions (networking.*, dmi.*); verify against a live
factset (`GET /pdb/query/v4/factsets`) before trusting output on a new estate.
"""
import argparse
import json
import os
import sys
import urllib.parse
import urllib.request
DEFAULT_URL = "http://puppetdb.puppet.svc.cluster.local:8080/pdb/query/v4/facts"
def query_facts(url, certname):
ast = json.dumps(["=", "certname", certname])
full = url + "?" + urllib.parse.urlencode({"query": ast})
with urllib.request.urlopen(full, timeout=30) as resp:
rows = json.load(resp)
return {row["name"]: row["value"] for row in rows}
def device_reality(certname, facts):
networking = facts.get("networking", {}) or {}
ifaces = networking.get("interfaces", {}) or {}
dmi = facts.get("dmi", {}) or {}
product = dmi.get("product", {}) or {}
interfaces = []
for name, data in sorted(ifaces.items()):
if name == "lo":
continue
ips = []
for b in (data.get("bindings", []) or []):
if b.get("address"):
ips.append(b["address"])
if data.get("ip") and data["ip"] not in ips:
ips.append(data["ip"])
interfaces.append({
"name": name,
"mac": data.get("mac"),
"ips": ips,
})
return {
"device": certname.split(".")[0],
"serial": product.get("serial_number") or facts.get("serialnumber"),
"model": product.get("name") or facts.get("productname"),
"uuid": product.get("uuid") or facts.get("uuid"),
"interfaces": interfaces,
}
def to_yaml(d):
# tiny dependency-free YAML emitter for this fixed shape
out = [
f"device: {d['device']}",
f"serial: {d['serial'] or ''}",
f"model: {d['model'] or ''}",
f"uuid: {d['uuid'] or ''}",
"interfaces:",
]
for i in d["interfaces"]:
out.append(f" - name: {i['name']}")
out.append(f" mac: {i['mac'] or ''}")
out.append(" ips: [%s]" % ", ".join(i["ips"]))
return "\n".join(out) + "\n"
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("hosts", nargs="+", help="certnames or short hostnames")
ap.add_argument("--url", default=os.environ.get("PUPPETDB_URL", DEFAULT_URL))
ap.add_argument("--out", help="write <host>.yaml here instead of stdout")
args = ap.parse_args()
for host in args.hosts:
certname = host if "." in host else f"{host}.main.unkin.net"
try:
facts = query_facts(args.url, certname)
except Exception as e: # noqa: BLE001
print(f"# {certname}: query failed: {e}", file=sys.stderr)
continue
if not facts:
print(f"# {certname}: no facts (not in PuppetDB)", file=sys.stderr)
continue
text = to_yaml(device_reality(certname, facts))
if args.out:
os.makedirs(args.out, exist_ok=True)
path = os.path.join(args.out, f"{certname.split('.')[0]}.yaml")
with open(path, "w") as fh:
fh.write(text)
print(f"wrote {path}", file=sys.stderr)
else:
print(text)
if __name__ == "__main__":
main()