Files
terraform-netbox/ci/puppetdb_backfill.py
T
unkin-agent d7c05b00ac
ci/woodpecker/pr/plan Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
Initial terraform-netbox: NetBox prefixes, devices, platforms, custom fields
Carried over from terraform-infra, scoped to NetBox: Kea is managed by the
kea-operator CRs. Devices carried no platform and NetBox had no custom
fields, so bootapi could only ever render its default template.

- prefixes, DHCP-pool ip_ranges, gateways, networks, devices and the
  PuppetDB backfill script, minus every Kea resource, provider and token
- netbox_custom_field definitions from config/<region>/<dc>/custom-fields.yaml
- netbox_platform per declared platform, set as the device platform_id
- model_hint written to the device provision_template custom field
- state key infra/terraform/netbox, NetBox token from netbox/creds/terraform-netbox

pxe_enabled's value is deliberately unmanaged: it is runtime state that
bootapi's provisioned callback clears.
2026-10-03 15:38:02 +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()