#!/usr/bin/env python3
"""
Dump the OFFICE1-NETBOX RECORD (the VR1 working IPAM apex) to a reviewable JSON
artifact. READ-ONLY. NOTHING is ever written to NetBox.
WHY A SEPARATE SCRIPT FROM netbox/prod-draft-dump.py (operator-directed 2026-07-25):
one script per SOURCE, so each holds only its own credential/URL and the source can
never be confused. The two NetBoxes are DIFFERENT systems with DIFFERENT roles:
- prod-draft-dump.py -> netbox.baldurkeep.com : the FROZEN v1 REFERENCE (4.5.8,
bare 40-char v1 token). Takes NO write during VR1 (DOCFIX-195); it is the
eventual production apex, held read-only.
- THIS script -> office1-netbox 10.10.1.10:8000 : the VR1 WORKING APEX /
"the RECORD" (4.6.4, assembled v2 token nbt_<key>.<plaintext>). This is what
every VR1 consuming system (OpenTofu, MAAS, the overlays, the Juju bundle)
actually reads (DOCFIX-195), and what has been EDITED as the dc0/dc1 buildout
proceeded -- so this is the "current in-cloud NetBox" state.
Use this to capture the CURRENT apex for a design/review session that has no live infra
(e.g. Chat). It is a point-in-time snapshot; re-run to refresh.
TOKEN NEVER TRAVELS: the script reads NETBOX_URL + NETBOX_TOKEN from the environment;
source the record env first (below). It is not embedded and not printed (only the base
URL + per-endpoint counts are printed).
TRAP -- User-Agent (references/platform-traps.md): the baldurkeep upstream 403s the
default Python UA; office1-netbox does not have that WAF, but we send an accepted UA
regardless (harmless) so the same code path works against either 4.5/4.6 apex.
Usage:
. ~/vr1-office1-creds/vr1-netbox-sandbox.env # NETBOX_URL=http://10.10.1.10:8000 + v2 NETBOX_TOKEN
python3 netbox/office1-record-dump.py --out netbox/draft/vr1-office1-current-YYYYMMDD.json
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
UA = "curl/8.5.0"
# Same IPAM object set + dependency order as prod-draft-dump.py, so the two apexes
# produce structurally comparable dumps (a fidelity diff is then meaningful).
ENDPOINTS = [
("ipam/rirs", ["name", "slug", "is_private", "description"]),
("ipam/roles", ["name", "slug", "weight", "description"]),
("dcim/regions", ["name", "slug", "parent", "description"]),
("dcim/sites", ["name", "slug", "status", "region", "description"]),
("ipam/aggregates", ["prefix", "rir", "description"]),
("ipam/prefixes", ["prefix", "status", "role", "scope_type", "scope_id",
"description", "is_pool", "mark_utilized"]),
("ipam/ip-ranges", ["start_address", "end_address", "status", "role", "description"]),
("ipam/ip-addresses", ["address", "status", "role", "dns_name", "description"]),
]
def die(msg):
print("FAIL: %s" % msg, file=sys.stderr)
sys.exit(2)
def require_env(name):
v = os.environ.get(name)
if not v:
die("%s is not set. Source the RECORD env first: "
". ~/vr1-office1-creds/vr1-netbox-sandbox.env" % name)
return v
def get_all(base, token, path):
"""Paginate an endpoint fully. Read-only."""
out = []
url = "%s/api/%s/?limit=200" % (base, path)
while url:
req = urllib.request.Request(url, headers={
"Authorization": "Token %s" % token, # v2 wire form: Token nbt_<key>.<plaintext>
"Accept": "application/json",
"User-Agent": UA,
})
try:
with urllib.request.urlopen(req, timeout=45) as r:
data = json.load(r)
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
die("HTTP %d on %s -- check the RECORD env token is the ASSEMBLED v2 form "
"(nbt_<key>.<plaintext>), not the API's bare token field." % (exc.code, path))
die("HTTP %d on %s: %s" % (exc.code, path, exc.reason))
out += data["results"]
url = data.get("next") or None
return out
def slim(obj, fields):
"""Keep only what a downstream fidelity/seed step needs; nested refs -> slug/value.
IDs are DISCARDED (they are apex-local and would mis-link on the way into another apex)."""
rec = {}
for f in fields:
v = obj.get(f)
rec[f] = (v.get("slug") or v.get("value")) if isinstance(v, dict) else v
return rec
def rewrite_prefix_scope(rec, obj, site_id_to_slug, region_id_to_slug):
"""Rewrite a prefix's scope_id (apex-local id) to a slug the reader can re-resolve."""
sid, st = obj.get("scope_id"), obj.get("scope_type")
if sid is not None and st == "dcim.site":
rec["scope_site"] = site_id_to_slug.get(sid)
elif sid is not None and st == "dcim.region":
rec["scope_region"] = region_id_to_slug.get(sid)
rec.pop("scope_id", None)
return rec
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
ap.add_argument("--out", required=True, help="Path to write the JSON snapshot")
args = ap.parse_args()
base = require_env("NETBOX_URL").rstrip("/")
token = require_env("NETBOX_TOKEN")
print("Reading the office1-netbox RECORD from %s (READ-ONLY) ..." % base)
raw_sites = get_all(base, token, "dcim/sites")
site_id_to_slug = {s["id"]: s["slug"] for s in raw_sites}
raw_regions = get_all(base, token, "dcim/regions")
region_id_to_slug = {r["id"]: r["slug"] for r in raw_regions}
draft = {"_source": base,
"_note": "READ-ONLY snapshot of the office1-netbox RECORD (VR1 working apex, "
"DOCFIX-195). IDs discarded; refs by slug/prefix."}
for path, fields in ENDPOINTS:
rows = raw_sites if path == "dcim/sites" else get_all(base, token, path)
slimmed = []
for o in rows:
rec = slim(o, fields)
if path == "ipam/prefixes":
rec = rewrite_prefix_scope(rec, o, site_id_to_slug, region_id_to_slug)
if path == "ipam/ip-ranges":
rec["range"] = "%s-%s" % (rec.get("start_address"), rec.get("end_address"))
slimmed.append(rec)
draft[path] = slimmed
print(" %-18s %d" % (path, len(slimmed)))
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "w") as fh:
json.dump(draft, fh, indent=2, sort_keys=True)
fh.write("\n")
print("\nWrote %s. NOTHING was written to NetBox." % args.out)
return 0
if __name__ == "__main__":
sys.exit(main())