Files
bf-infra/nautobot/ingest-crs326-ports.py
Christoph Emsenhuber b52d6aa49e TP45 Schicht A: Nautobot .30/.31/.32/.34 -> .154/.155/.156/.158
Oktett-Klasse .150-.199 (App-Services). Nautobot gibt damit .30/.31 an
AGH frei. .157 ist gitea, deshalb nimmt redis .158.

- Datenmigration ueber neues nautobot/tp45-ip-move.py: Nautobot verbietet
  das Aendern einer IP nach dem Anlegen -> anlegen/primary_ip4 umhaengen/
  Altobjekt loeschen, in dieser Reihenfolge
- Stack-Redeploy (PUT /api/stacks/203), Caddy-Backend, Core-Literal *24
  ueber GC deployt (Plan 51a90e25) mit Wirksamkeitspruefung am Geraet
- caddy/deploy.sh: push_verified() ersetzt den push||push-Fallback.
  `pct push` meldet Exit 0 AUCH wenn es die Quelle nicht lesen kann, der
  Fallback griff deshalb nie und `caddy validate` pruefte die ALTE Datei
  -> gruene Abnahme bei unveraendertem Zustand (Symptom: 502 bei
  gesundem Backend)
- 39 Referenzen in 28 Dateien mitgezogen; lebende Doku nachgezogen
  (CLAUDE.md, ip-plan.md, cf-tunnel-mapping §0-Kopf, TP16.3/TP31-Prosa)
- GC-Compliance 140/140, 0 Drifts

Neue Funde (TP45-Prosa + CLAUDE.md):
- Die Core-Adressliste `nautobot` wird von GC nirgends gerendert -> ein
  Recovery von null erzeugt einen Core, in den Nautobot nicht mehr per
  SSH kommt. Gleiche Klasse wie die fehlenden DHCP-Reservierungen.
- Regel *24 (Literal) und *28 (Liste) sind Dubletten; der Literal steht
  bei 0 Paketen. Zusammenlegung bleibt offen.
- gc-manual-plan.py: /api/extras/job-log-entries/ liefert 404 (Nautobot
  3.x) -> bei gescheitertem Deploy fehlt die Fehlermeldung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 13:14:35 +02:00

220 lines
10 KiB
Python

#!/usr/bin/env python3
"""BF-Netzwerk -> Nautobot: CRS326-Portplan + sw1-Belegung (TP15 As-built, 2026-07-23).
Ergaenzt ingest-baseline.py: schreibt den Ist-Stand des CRS326-Bring-ups (Governance-Auflage
"intended config zurueckschreiben") sowie die vom User verifizierte sw1-Belegung nach Nautobot.
crs326-branch : volle SOLL-Portbelegung (produktiv), mgmt 10.40.99.13, Status Active
crs326-core : Bring-up-Interfaces + geplantes Layout (tentativ), mgmt 10.40.99.11, Status Planned
sw1 (ALLNET) : verifizierte 16-Port-Belegung (loest "UNVERIFIZIERT" auf)
sw2 (ES208G) : Status -> Decommissioning (wird durch crs326-branch ersetzt)
Muster wie ingest-baseline.py: get-or-create + Delta-PATCH, idempotent.
Verifikation NUR per GraphQL (tagged_vlans liest sich per REST als 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 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"]
ETH, SFP, VIRT, BR = "1000base-t", "10gbase-x-sfpp", "virtual", "bridge"
S_ACTIVE = sid("Active")
S_PLANNED = sid("Planned")
S_DECOM = sid("Decommissioning")
# VLAN-Objekte (aus ingest-baseline angelegt)
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"])
ns_global = find("/ipam/namespaces", name="Global")["id"]
D = {n: find("/dcim/devices", name=n) for n in ("crs326-branch", "crs326-core", "sw1", "sw2")}
def iface(dev, name, typ, desc, mode=None, untagged=None, tagged=None):
"""Interface get-or-create + Attribute-PATCH (Mode/VLANs)."""
obj = goc("/dcim/interfaces", {"device": dev, "name": name},
{"device": D[dev]["id"], "name": name, "type": typ,
"status": S_ACTIVE, "description": desc}, label=f"{dev}:{name}")
p = {"description": desc, "type": typ}
if mode:
p["mode"] = mode
if untagged is not None:
p["untagged_vlan"] = V[untagged]["id"]
if not DRY and not str(obj["id"]).startswith("DRY"):
obj = patch("/dcim/interfaces", obj, p, f"{dev}:{name}")
if tagged is not None:
req("PATCH", f"/dcim/interfaces/{obj['id']}/",
{"tagged_vlans": [V[t]["id"] for t in tagged]})
return obj
def set_mgmt(dev, ifname, addr, desc):
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"):
return
intf = find("/dcim/interfaces", device=dev, name=ifname)
if not find("/dcim/interfaces", device=dev, name=ifname, ip_addresses=ip["id"]):
try:
req("POST", "/ipam/ip-address-to-interface/",
{"ip_address": ip["id"], "interface": intf["id"]})
log.append(f" + {addr} -> {dev}:{ifname}")
except urllib.error.HTTPError:
log.append(f" = {addr} -> {dev}:{ifname} (bereits)")
req("PATCH", f"/dcim/devices/{D[dev]['id']}/", {"primary_ip4": ip["id"]})
TRUNK_BRANCH = [10, 20, 30, 40, 50, 90, 99] # kein VLAN100 auf branch (WAN nur zu ap1)
print("== crs326-branch: SOLL-Portplan (produktiv)")
patch("/dcim/devices", D["crs326-branch"], {"status": S_ACTIVE}, "crs326-branch status")
iface("crs326-branch", "bridge", BR, "vlan-filtering=yes; VLAN1 untagged, 10-99 tagged")
iface("crs326-branch", "ether1", ETH, "Uplink-Trunk -> sw1 #8 (native VLAN1 + tagged 10/20/30/40/50/90/99, KEIN 100)",
mode="tagged", untagged=1, tagged=TRUNK_BRANCH)
iface("crs326-branch", "ether2", ETH, "NAS #1 (tagged VLAN20)", mode="tagged", tagged=[20])
iface("crs326-branch", "ether3", ETH, "Drucker (Access VLAN10)", mode="access", untagged=10)
iface("crs326-branch", "ether4", ETH, "Monitor/Dock Laptop (Access VLAN10)", mode="access", untagged=10)
iface("crs326-branch", "ether5", ETH, "Sonos (Access VLAN40)", mode="access", untagged=40)
iface("crs326-branch", "ether6", ETH, "Sonos (Access VLAN40)", mode="access", untagged=40)
iface("crs326-branch", "ether7", ETH, "Mac mini (Access VLAN20)", mode="access", untagged=20)
iface("crs326-branch", "ether8", ETH, "NAS #2 (tagged VLAN20)", mode="tagged", tagged=[20])
for n in range(9, 24):
iface("crs326-branch", f"ether{n}", ETH, "frei (disabled)")
iface("crs326-branch", "ether24", ETH, "ap2 (Trunk; native VLAN1 + tagged 10-99)",
mode="tagged", untagged=1, tagged=TRUNK_BRANCH)
iface("crs326-branch", "sfp-sfpplus1", SFP, "frei (disabled)")
iface("crs326-branch", "sfp-sfpplus2", SFP, "frei (disabled)")
iface("crs326-branch", "vlan99", VIRT, "Mgmt SVI (VLAN99)", mode="tagged", tagged=[99])
if not DRY:
v99 = find("/dcim/interfaces", device="crs326-branch", name="vlan99")
br = find("/dcim/interfaces", device="crs326-branch", name="bridge")
req("PATCH", f"/dcim/interfaces/{v99['id']}/", {"parent_interface": br["id"]})
set_mgmt("crs326-branch", "vlan99", "10.40.99.13/24",
"TP15 Bring-up-Mgmt (geplanter Renumber -> 10.40.99.12 nach Austausch beider Switches)")
req_patch = {"comments": "TP15 2026-07-23: grundkonfiguriert via REST (RouterOS). MAC 04:F4:1C:B4:15:58, "
"software-id RGZJ-UK0T, Serial HKA0AJJA52F. Ersetzt sw2 (Technik OG). Bring-up-Mgmt 10.40.99.13 "
"(+ VLAN1-Temp 10.40.41.213 waehrend Bring-up); Ziel 10.40.99.12. ap2 an ether24."}
if not DRY:
req("PATCH", f"/dcim/devices/{D['crs326-branch']['id']}/", req_patch)
print("== crs326-core: Bring-up + geplantes Layout (tentativ, Status bleibt Planned)")
iface("crs326-core", "bridge", BR, "vlan-filtering=yes")
iface("crs326-core", "ether1", ETH, "Uplink-Trunk -> sw1 #12 (Bring-up-Pfad; native VLAN1 + tagged 10-100)",
mode="tagged", untagged=1, tagged=[10, 20, 30, 40, 50, 90, 99, 100])
iface("crs326-core", "ether2", ETH, "Bring-up: Temp-Downlink zu branch ether1. GEPLANT (tentativ): Sonos VLAN40",
mode="tagged", untagged=1, tagged=[10, 20, 30, 40, 50, 90, 99, 100])
iface("crs326-core", "ether3", ETH, "GEPLANT (tentativ): Sonos VLAN40 untagged")
iface("crs326-core", "ether4", ETH, "GEPLANT: ap1-Uplink-Trunk (ap1 wandert #15->#4 in Core-Swap-Session)")
iface("crs326-core", "ether5", ETH, "GEPLANT (tentativ): Sonos VLAN40 untagged")
iface("crs326-core", "vlan99", VIRT, "Mgmt SVI (VLAN99)", mode="tagged", tagged=[99])
if not DRY:
v99c = find("/dcim/interfaces", device="crs326-core", name="vlan99")
brc = find("/dcim/interfaces", device="crs326-core", name="bridge")
req("PATCH", f"/dcim/interfaces/{v99c['id']}/", {"parent_interface": brc["id"]})
set_mgmt("crs326-core", "vlan99", "10.40.99.11/24", "TP15 Bring-up-Mgmt-Ziel (VLAN99)")
if not DRY:
req("PATCH", f"/dcim/devices/{D['crs326-core']['id']}/",
{"comments": "TP15 2026-07-23: grundkonfiguriert via REST (Bring-up-Level, NICHT produktiv). "
"MAC 04:F4:1C:B4:17:76, software-id 4W04-URHT, Serial HKA0AREXJFM. Ersetzt sw1 = kuenftiger L3-Router. "
"Aktuell an sw1 #12. Mgmt 10.40.99.11 (VLAN99, erreichbar erst wenn sw1 #12 99T traegt) + VLAN1-Temp "
"10.40.41.211. Port-Layout ab ether3 tentativ (VLAN99 pro Port unter Vorbehalt), final in Core-Swap-Session."})
print("== sw1 (ALLNET): verifizierte Belegung (User-Survey 2026-07-22) -> loest UNVERIFIZIERT")
SW1 = [
("GE1", "Trunk (native VLAN1)"),
("GE2", "Sonos (Access VLAN40) - Wohnzimmer"),
("GE3", "Hybrid VLAN1 - Wohnzimmer Sideboard"),
("GE4", "Trunk all (1U,10-100T) - ALT.-Standort ap1 (WLAN) - Wohnzimmer Sideboard"),
("GE5", "Sonos (Access VLAN40) - Schlafzimmer"),
("GE6", "Sonos (Access VLAN40) - Schlafzimmer"),
("GE7", "Hybrid VLAN1 - Arbeitszimmer (Leitung evtl. tot)"),
("GE8", "Trunk (1U,10-99T) -> crs326-branch Uplink - Arbeitszimmer"),
("GE9", "Sonos (Access VLAN40) - Kinderzimmer"),
("GE10", "Hybrid VLAN1 - Kinderzimmer"),
("GE11", "cudy1 (Access VLAN10) - Technik UG"),
("GE12", "crs326-core Bring-up/Config (aktuell Hybrid 1U, Ziel Trunk 1U,20-99T) - Technik UG"),
("GE13", "pve (Trunk 1U,20-99T) - Technik UG"),
("GE14", "Sonoff Dongle-M (Access VLAN30) - Technik UG"),
("GE15", "ap1 (Trunk 1U,10-100T) - Technik UG"),
("GE16", "Arris-Kabelmodem (Access VLAN100/WAN) - Technik UG"),
]
for name, desc in SW1:
iface("sw1", name, ETH, desc)
print("== sw2 (ES208G): -> Decommissioning")
patch("/dcim/devices", D["sw2"], {"status": S_DECOM}, "sw2 status")
if not DRY:
req("PATCH", f"/dcim/devices/{D['sw2']['id']}/",
{"comments": "TP15 2026-07-23: wird durch crs326-branch ersetzt (Technik OG). Physischer Tausch = "
"sw2-Geraetekabel auf branch ether1-8, Uplink auf sw1 #8, dann sw2 stromlos/ausgebaut."})
print("\n".join(log))
print(f"\n{'DRY-RUN - nichts geschrieben. Mit --apply ausfuehren.' if DRY else 'ANGEWENDET.'}")