154 lines
5.9 KiB
Python
154 lines
5.9 KiB
Python
#!/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.<vlan>.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.30: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.'}")
|