#!/usr/bin/env python3
"""
Dump the UPSTREAM NetBox draft to a reviewable JSON artifact. READ-ONLY.
This is phase 1 of the two-phase sandbox seeding loop documented in
docs/session-ledger.md:
upstream (draft source) --[this script, READ-ONLY]--> vr1-draft.json
vr1-draft.json --[netbox/sandbox-seed.py]--> Office1 sandbox
...simulate VR1 in the sandbox, apply planned changes (D-115/D-117)...
validated refinements --[operator-gated]----------> upstream
WHY TWO PHASES instead of one source->dest script:
1. The upstream token NEVER travels. This script runs on vcloud (where
~/.vr1-netbox.env lives); sandbox-seed.py runs on office1-netbox (where
the sandbox token lives). Neither host holds the other's credential.
2. "The sim NEVER writes upstream" becomes STRUCTURAL, not a discipline: the
script that writes has no upstream credentials and no upstream URL.
3. The JSON dump is a reviewable, diffable artifact. You can read exactly what
is about to be seeded before anything is written.
This script performs NO writes of any kind. It has no --commit flag because it
has nothing to commit.
TRAP -- User-Agent (measured 2026-07-13, see references/platform-traps.md):
upstream NetBox sits behind a filter that 403s the default Python User-Agent.
The SAME token works from curl. It looks exactly like an auth failure and it is
not. We send an accepted UA. pynetbox (python-requests/...) is bitten too.
Usage:
. ~/.vr1-netbox.env # NETBOX_URL + NETBOX_TOKEN
python3 netbox/prod-draft-dump.py --out netbox/draft/vr1-draft.json
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
# Upstream is 4.5.8 and uses v1 (bare, 40-char) tokens. The Office1 sandbox is
# 4.6.4 and uses v2 (nbt_<key>.<plaintext>). Do NOT assume one shape fits both.
UA = "curl/8.5.0"
# What the VR1 carve actually needs to exist. Order is DEPENDENCY order and the
# seeder replays it verbatim -- parents before children.
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"]),
]
def die(msg: str) -> "None":
print(f"FAIL: {msg}", file=sys.stderr)
sys.exit(2)
def require_env(name: str) -> str:
v = os.environ.get(name)
if not v:
die(f"{name} is not set. Source the upstream env first: . ~/.vr1-netbox.env")
return v
def get_all(base: str, token: str, path: str) -> list:
"""Paginate an endpoint fully. Read-only."""
out = []
url = f"{base}/api/{path}/?limit=200"
while url:
req = urllib.request.Request(url, headers={
"Authorization": f"Token {token}",
"Accept": "application/json",
"User-Agent": UA, # <-- the WAF trap. Do not remove.
})
try:
with urllib.request.urlopen(req, timeout=45) as r:
data = json.load(r)
except urllib.error.HTTPError as exc:
if exc.code == 403:
die(f"403 on {path}. If curl works with this same token, it is the "
f"User-Agent filter, NOT the token -- see references/platform-traps.md.")
die(f"HTTP {exc.code} on {path}: {exc.reason}")
out += data["results"]
nxt = data.get("next")
url = nxt if nxt else None
return out
def slim(obj: dict, fields: list) -> dict:
"""Keep only what the seeder needs, resolving nested refs to SLUGS.
IDs are deliberately DISCARDED. A sandbox assigns its own ids; carrying
upstream ids across would either collide or silently mis-link. Everything is
re-linked by slug/prefix on the way in.
"""
rec = {}
for f in fields:
v = obj.get(f)
if isinstance(v, dict):
# nested object (role, rir, region, parent, status) -> slug or value
rec[f] = v.get("slug") or v.get("value")
else:
rec[f] = v
return rec
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
ap.add_argument("--out", required=True, help="Path to write the JSON draft artifact")
args = ap.parse_args()
base = require_env("NETBOX_URL").rstrip("/")
token = require_env("NETBOX_TOKEN")
# Site id -> slug, so prefix scope_id (an upstream id) can be rewritten to a
# slug the seeder can re-resolve against ITS OWN site ids.
print(f"Reading upstream draft from {base} (READ-ONLY) ...")
raw_sites = get_all(base, token, "dcim/sites")
site_id_to_slug = {s["id"]: s["slug"] for s in raw_sites}
# Prefixes can be scoped to a REGION as well as a site -- and most of the VR1
# draft is region-scoped (the f00/f01 containers hang off region VR1, not off
# a site). Mapping only dcim.site silently DROPPED those scopes on the way in.
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 dump. IDs discarded; refs are by slug."}
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":
# scope_id is an upstream id -- rewrite to a slug, or drop the scope.
sid = o.get("scope_id")
st = o.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)
slimmed.append(rec)
draft[path] = slimmed
print(f" {path:<18} {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(f"\nWrote {args.out}. NOTHING was written to NetBox.")
return 0
if __name__ == "__main__":
sys.exit(main())