9a6f775300
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
115 lines
4.0 KiB
Python
Executable File
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()
|