#!/usr/bin/env python3 """BF-Netzwerk -> Nautobot: crs326-core L3-Datenmodell ergaenzen (TP15 Cutover-Prep). Erstellt fuer crs326-core: - vlan10..vlan90, vlan100 als virtuelle Bridge-Subinterfaces (tagged) - GW-IPs 10.40..1/24 (v10..v90) - Primaer-IP bleibt 10.40.99.11/24 (Bring-up-Mgmt) - Rolle Router, Status bleibt Planned bis Cutover Idempotent; verwendet GraphQL fuer tagged_vlans (REST liefert null). """ import json, os, sys, urllib.request, urllib.error, urllib.parse BASE = "http://10.40.20.154:8080/api" TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") DRY = "--apply" not in sys.argv log = [] def req(method, path, data=None): url = path if path.startswith("http") else f"{BASE}{path}" body = json.dumps(data).encode() if data is not None else None r = urllib.request.Request(url, data=body, method=method) r.add_header("Authorization", f"Token {TOKEN}") r.add_header("Content-Type", "application/json") r.add_header("Accept", "application/json") try: with urllib.request.urlopen(r, timeout=30) as resp: raw = resp.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: print(f"!! {method} {url}\n payload={json.dumps(data)}\n -> {e.code} {e.read().decode()[:500]}") raise def gql(query): r = urllib.request.Request(f"{BASE}/graphql/", data=json.dumps({"query": query}).encode(), method="POST") r.add_header("Authorization", f"Token {TOKEN}") r.add_header("Content-Type", "application/json") with urllib.request.urlopen(r, timeout=30) as resp: data = json.loads(resp.read()) if data.get("errors"): print("GraphQL errors:", data["errors"]) raise SystemExit(1) return data["data"] def find(path, **params): q = "&".join(f"{k}={urllib.parse.quote(str(v))}" for k, v in params.items()) res = req("GET", f"{path}?{q}&limit=2") return res["results"][0] if res.get("count") else None def goc(path, match, payload, label=None): name = label or payload.get("name") or str(match) obj = find(path, **match) if obj: log.append(f" = {name}") return obj if DRY: log.append(f" + {name} (dry-run)") return {"id": f"DRY-{name}"} obj = req("POST", path + "/", payload) log.append(f" + {name}") return obj def patch(path, obj, payload, label): delta = {} for k, v in payload.items(): cur = obj.get(k) cur_id = cur.get("id") if isinstance(cur, dict) else cur if cur_id != v: delta[k] = v if not delta: log.append(f" = {label} (unveraendert)") return obj if DRY: log.append(f" ~ {label}: {list(delta)} (dry-run)") return obj out = req("PATCH", f"{path}/{obj['id']}/", delta) log.append(f" ~ {label}: {list(delta)}") return out def sid(name): return find("/extras/statuses", name=name)["id"] S_ACTIVE = sid("Active") S_PLANNED = sid("Planned") role_router = find("/extras/roles", name="Router") ns_global = find("/ipam/namespaces", name="Global")["id"] vg = find("/ipam/vlan-groups", name="BF") V = {} for vid in (1, 10, 20, 30, 40, 50, 90, 99, 100): V[vid] = find("/ipam/vlans", vid=vid, vlan_group=vg["id"]) core = find("/dcim/devices", name="crs326-core") print("== crs326-core: Rolle + Status") patch("/dcim/devices", core, {"role": role_router["id"]}, "crs326-core role=Router") print("== crs326-core: VLAN-Subinterfaces (L3 SVIs)") br = find("/dcim/interfaces", device="crs326-core", name="bridge") for vid in (10, 20, 30, 40, 50, 90, 99, 100): ifname = f"vlan{vid}" desc = f"L3 SVI VLAN{vid}" if vid != 99 else "L3 SVI VLAN99 (Mgmt + Router-GW)" obj = goc("/dcim/interfaces", {"device": core["id"], "name": ifname}, {"device": core["id"], "name": ifname, "type": "virtual", "status": S_ACTIVE, "description": desc, "mode": "tagged", "parent_interface": br["id"], "tagged_vlans": [V[vid]["id"]]}, label=f"core:{ifname}") if not DRY and not str(obj["id"]).startswith("DRY"): # Nachträglich Parent/Mode/VLAN sicherstellen (goc legt nur an, patch bei Änderung) req("PATCH", f"/dcim/interfaces/{obj['id']}/", {"parent_interface": br["id"], "mode": "tagged", "tagged_vlans": [V[vid]["id"]]}) print("== crs326-core: GW-IPs je VLAN (v10..v99)") IF = {} for vid in (10, 20, 30, 40, 50, 90, 99): IF[vid] = find("/dcim/interfaces", device="crs326-core", name=f"vlan{vid}") for vid in (10, 20, 30, 40, 50, 90, 99): addr = f"10.40.{vid}.1/24" desc = f"VLAN{vid} GW (core, staged)" ip = find("/ipam/ip-addresses", address=addr) if not ip: ip = goc("/ipam/ip-addresses", {"address": addr}, {"address": addr, "status": S_ACTIVE, "description": desc, "namespace": ns_global}, label=addr) if DRY or str(ip["id"]).startswith("DRY"): continue ex = find("/dcim/interfaces", device="crs326-core", name=f"vlan{vid}", ip_addresses=ip["id"]) if not ex: try: req("POST", "/ipam/ip-address-to-interface/", {"ip_address": ip["id"], "interface": IF[vid]["id"]}) log.append(f" + {addr} -> core:vlan{vid}") except urllib.error.HTTPError: log.append(f" = {addr} -> core:vlan{vid} (bereits)") print("== crs326-core: Kommentar (L3-Router-Ziel)") if not DRY: req("PATCH", f"/dcim/devices/{core['id']}/", {"comments": "TP15 2026-07-23: grundkonfiguriert via REST (Bring-up-Level). " "MAC 04:F4:1C:B4:17:76, software-id 4W04-URHT, Serial HKA0AREXJFM. " "Wird neuer L3-Router (Routing/DHCP/DNS/NAT von ap1 -> core). " "Mgmt 10.40.99.11 (VLAN99) + GW-IPs .1 je Client-VLAN staged. " "VLAN1-Temp 10.40.41.211. Status Planned bis Cutover."}) print("\n".join(log)) print(f"\n{'DRY-RUN - nichts geschrieben. Mit --apply ausfuehren.' if DRY else 'ANGEWENDET.'}")