#!/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 .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()