Newer
Older
openstack-caracal-dc-dc / netbox / dc-edge-wan-import.py
#!/usr/bin/env python3
"""
Register the VR1 DC-edge simulated-ISP WAN /24s in office1-netbox (the VR1 apex).

D-115 (ADOPTED) carved a v4 Edge role at 172.30.0.0/16 and blessed office1-wan as
172.30.1.0/24 (site vr1-off1). The two DC edges follow the same role, one /24 each:

  172.30.2.0/24   role edge, active   scope=dcim.site:vr1-dc0   (vr1-dc0 sim-ISP WAN)
  172.30.3.0/24   role edge, active   scope=dcim.site:vr1-dc1   (vr1-dc1 sim-ISP WAN)

These were NOT in the D-115 office carve (which only owned the office row) and no
prior import covered the DC-edge /24s -- so this tool registers them, and nothing
else. It creates NEITHER the Edge role NOR the 172.30.0.0/16 container NOR the DC
sites: all three are PRECONDITIONS (d115-office-carve.py owns the role + container;
the vr1-dc0/vr1-dc1 sites already exist in the apex). A missing precondition is a
hard STOP, never a silent create.

DRY BY DEFAULT -- nothing is written without --commit.

WRITING UPSTREAM IS GATED IN CODE. SANDBOX_HOSTS is the allowlist; a --commit at
any other host REFUSES without --yes-write-upstream. WHOLE-PLAN PREFLIGHT: the role,
the container, the in-container placement of every /24, and every target site are all
checked BEFORE any create, so a bad plan cannot half-write the apex (the failure that
bit roles-aggregates-import.py -- it created 4 objects then died on the 5th).

Usage (on office1-netbox / through a tunnel, with the sandbox token):
    NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN=<tok> python3 netbox/dc-edge-wan-import.py
    ... same, add --commit, to write.
"""
import argparse
import ipaddress
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request

UA = "curl/8.5.0"   # upstream 403s the default python UA -- see platform-traps.md

# A sandbox is local, or the known Office1 sandbox address. Anything else is treated
# as the production apex and requires the explicit upstream flag.
SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.10"}

ROLE_SLUG = "edge"                 # created by d115-office-carve.py; a precondition here
CONTAINER = "172.30.0.0/16"        # Edge role container; a precondition here
STATUS = "active"                  # a real segment on the wire, like office1-wan .1/24

# (cidr, target-site slug, description). Site-scoped exactly like office1-wan
# 172.30.1.0/24 -> vr1-off1 (d115-office-carve.py). The DC sites already exist in the
# apex draft (vr1-dc0 / vr1-dc1); this tool resolves them, it never creates them.
TARGETS = [
    ("172.30.2.0/24", "vr1-dc0", "vr1-dc0 simulated-ISP edge WAN (D-115)"),
    ("172.30.3.0/24", "vr1-dc1", "vr1-dc1 simulated-ISP edge WAN (D-115)"),
]


def die(msg: str):
    print(f"FAIL: {msg}", file=sys.stderr)
    sys.exit(2)


class NB:
    """Stdlib NetBox client -- same shape as the other sandbox-loop tools; UA-aware so
    it is not 403'd by the upstream User-Agent filter."""
    def __init__(self, base, token):
        self.base = base.rstrip("/")
        self.token = token

    def _req(self, method, path, body=None):
        data = json.dumps(body).encode() if body is not None else None
        req = urllib.request.Request(f"{self.base}/api/{path}", data=data, method=method,
                                     headers={"Authorization": f"Token {self.token}",
                                              "Accept": "application/json",
                                              "Content-Type": "application/json",
                                              "User-Agent": UA})
        try:
            with urllib.request.urlopen(req, timeout=45) as r:
                return json.load(r) if r.status != 204 else None
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode(errors="replace")[:300]
            if exc.code == 403 and "v1 token" in detail:
                die("403 'Invalid v1 token' -- NetBox 4.6 wants the ASSEMBLED v2 token "
                    "nbt_<key>.<plaintext>, not the API's bare `token` field.")
            if exc.code == 403:
                die(f"403 on {path}. If curl works with this token, it is the upstream "
                    f"User-Agent filter, NOT the token (references/platform-traps.md).")
            die(f"HTTP {exc.code} {method} {path}: {detail}")

    def one(self, path, **flt):
        res = self._req("GET", f"{path}/?{urllib.parse.urlencode(flt)}&limit=1")
        return res["results"][0] if res["results"] else None

    def create(self, path, payload):
        return self._req("POST", f"{path}/", payload)


def get_nb(base, token):
    """Client factory -- the injection seam the harness overrides to drive main()
    against an in-memory fake without a live NetBox."""
    return NB(base, token)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
    ap.add_argument("--commit", action="store_true",
                    help="WRITE. Default is a DRY RUN that writes nothing.")
    ap.add_argument("--yes-write-upstream", action="store_true",
                    help="Required (with --commit) to write to a NON-sandbox NetBox.")
    args = ap.parse_args()

    url = os.environ.get("NETBOX_URL")
    token = os.environ.get("NETBOX_TOKEN")
    if not url or not token:
        die("NETBOX_URL and NETBOX_TOKEN must be set.")

    host = (urllib.parse.urlparse(url).hostname or url).lower()
    is_sandbox = host in SANDBOX_HOSTS
    print(f"Target : {url}   ({'SANDBOX' if is_sandbox else 'NOT a known sandbox'})")
    if args.commit and not is_sandbox and not args.yes_write_upstream:
        die(f"REFUSING to --commit to '{host}': not a known sandbox, so treated as the "
            f"PRODUCTION apex. Re-run with --yes-write-upstream if that is intended.")

    print("\n*** DRY RUN -- nothing will be written. Re-run with --commit. ***"
          if not args.commit else "\n*** COMMITTING. ***")

    nb = get_nb(url, token)

    # WHOLE-PLAN PREFLIGHT -- validate the ENTIRE plan (role, container, in-container
    # placement, every target site) BEFORE any create, so a later-target failure (e.g.
    # a missing vr1-dc1 site) cannot leave vr1-dc0 written and the apex half-populated.
    container = ipaddress.ip_network(CONTAINER)
    for cidr, _site_slug, _desc in TARGETS:
        if not ipaddress.ip_network(cidr).subnet_of(container):
            die(f"{cidr} is outside the Edge container {CONTAINER} -- refusing to place it.")

    role = nb.one("ipam/roles", slug=ROLE_SLUG)
    if role is None:
        die(f"role '{ROLE_SLUG}' absent -- seed the D-115 carve first "
            f"(netbox/d115-office-carve.py). Refusing to place edge /24s with no role.")

    if nb.one("ipam/prefixes", prefix=CONTAINER) is None:
        die(f"Edge container {CONTAINER} absent -- seed the D-115 carve first "
            f"(netbox/d115-office-carve.py). Refusing to place /24s in an unallocated /16.")

    # Resolve every site and its already-present state up front -- THIS is the whole-plan
    # gate: if any site is missing the run dies here, before the create loop below runs.
    plan = []
    for cidr, site_slug, desc in TARGETS:
        site = nb.one("dcim/sites", slug=site_slug)
        if site is None:
            die(f"site '{site_slug}' absent -- cannot scope {cidr}. The DC sites are a "
                f"precondition (they already exist in the apex); this tool never creates them.")
        present = nb.one("ipam/prefixes", prefix=cidr) is not None
        plan.append((cidr, site, desc, present))

    created = existing = 0

    print("\nEdge DC /24s:")
    for cidr, site, desc, present in plan:
        if present:
            print(f"  EXISTS  {cidr}")
            existing += 1
            continue
        if not args.commit:
            print(f"  [dry-run] would CREATE {cidr}  role={ROLE_SLUG} "
                  f"scope=dcim.site:{site['slug']}")
            created += 1
            continue
        payload = {"prefix": cidr, "role": role["id"], "status": STATUS,
                   "description": desc, "scope_type": "dcim.site", "scope_id": site["id"]}
        o = nb.create("ipam/prefixes", payload)
        print(f"  CREATED {cidr} (id={o['id']}) role={ROLE_SLUG} scope={site['slug']}")
        created += 1

    verb = "would create" if not args.commit else "created"
    print(f"\n{'='*66}\n{verb}: {created}   already present: {existing}")
    if not args.commit:
        print("DRY RUN -- nothing was written. Re-run with --commit.")
    return 0


if __name__ == "__main__":
    sys.exit(main())