From 4d273117b6388e15f3c99d593c6e36ce07184e32 Mon Sep 17 00:00:00 2001 From: Christoph Emsenhuber Date: Mon, 21 Sep 2026 14:55:00 +0200 Subject: [PATCH] TP45 Schicht B1: DHCP-Reservierungen ins GC-Soll (Renderer + ComplianceRule) Die 54 statischen Reservierungen des Cores standen in KEINEM Soll: das Template rendert kein einziges /ip dhcp-server lease, und die Sektion stand unter keiner ComplianceRule. Ein GC-Recovery von null haette einen Core ohne Reservierungen erzeugt, in dem jedes IoT-Geraet in den Pool faellt -- und weil Compliance nur prueft, was gerendert wird, waere die Zahl dabei gruen geblieben. Dieselbe Luecke wie die handgelaufene `nautobot`-Adressliste. - mikrotik_routeros.j2 [5b]: rendert local_config_context_data.dhcp_leases, gleiches Idiom wie firewall_filter. - tp45-leases.py: seed/move/check. Zeilen werden verbatim uebernommen und nur im address=-Token getauscht -- das erhaelt Quoting und die 14 client-id-Felder. Reihenfolge bleibt die Geraete-Reihenfolge (Einfuege-, nicht numerisch). - ingest-dhcp-lease-rule.py: Feature+Rule `dhcp-lease` auf RouterOS, config_ordered=False. Noch KEIN Geraetewrite -- der Ist-Stand wurde als Soll gesetzt (Re-Baseline). Co-Authored-By: Claude Sonnet 4.6 --- nautobot/ingest-dhcp-lease-rule.py | 132 ++++++++++++++ nautobot/mikrotik_routeros.j2 | 19 +++ nautobot/tp45-leases.py | 266 +++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+) create mode 100644 nautobot/ingest-dhcp-lease-rule.py create mode 100644 nautobot/tp45-leases.py diff --git a/nautobot/ingest-dhcp-lease-rule.py b/nautobot/ingest-dhcp-lease-rule.py new file mode 100644 index 0000000..d77db31 --- /dev/null +++ b/nautobot/ingest-dhcp-lease-rule.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +ComplianceRule `dhcp-lease` anlegen (idempotent). + +Die Luecke, die das schliesst +----------------------------- +`nautobot/mikrotik_routeros.j2` rendert seit dem 2026-09-21 den Abschnitt +`/ip dhcp-server lease` (TP45 Schicht B, aus `local_config_context_data.dhcp_leases`). +Gerendert heisst aber noch nicht bewacht: ohne ComplianceRule erzeugt der Block +**keine** Compliance-Eintraege, und die Gesamtzahl bliebe unauffaellig. Genau die +Falle aus CLAUDE.md — "eine gleichbleibende Compliance-Zahl nach dem Hinzufuegen +eines Geraets ist ein BEFUND, kein Erfolg". + +Konkret: von 54 live existierenden Reservierungen war vorher **keine einzige** +drift-ueberwacht. Verschiebt jemand eine Adresse von Hand am Geraet, meldet +Golden Config "compliant", weil die Sektion unter keiner Rule steht. + +Analogie: TP26 fuer `/ip firewall filter` (25 Regeln blind), `ingest-queue-rule.py` +fuer `/queue`, `ingest-wifi-access-list-rule.py` fuer `/interface wifi access-list`, +`ingest-logging-rule.py` fuer `/system logging`. + + export NAUTOBOT_TOKEN=... # aus docs/nas-vlan20/stacks/210-nautobot.yml + python3 nautobot/ingest-dhcp-lease-rule.py # Dry-Run + python3 nautobot/ingest-dhcp-lease-rule.py --apply +""" +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +BASE = "http://10.40.20.154:8080/api" +TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") +APPLY = "--apply" in sys.argv + +FEATURE = "dhcp-lease" +# ⚠️ Nautobot begrenzt die Feature-Beschreibung auf 200 Zeichen (400 sonst). +BESCHREIBUNG = ("TP45 Schicht B: DHCP-Reservierungen (/ip dhcp-server lease). Bis 2026-09-21 " + "unter keiner Rule -- die 54 Reservierungen waren drift-blind. Gleicher " + "blinder Fleck wie TP26 fuer /ip firewall filter.") +# Praefix, nicht die ganze Sektion: `/ip dhcp-server lease` faengt nur die Reservierungen. +# Ein blankes `/ip dhcp-server` wuerde die Server-, Network- und Option-Zeilen mitfangen, +# die eigene Rules haben (bzw. haben sollen). +MATCH = "/ip dhcp-server lease" +PLATTFORM = "RouterOS" + + +def req(method, path, data=None): + url = path if path.startswith("http") else 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", "Token " + TOKEN) + r.add_header("Content-Type", "application/json") + r.add_header("Accept", "application/json") + try: + with urllib.request.urlopen(r, timeout=60) as resp: + raw = resp.read() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + sys.exit("!! %s %s -> %s\n %s" % (method, url, e.code, e.read().decode()[:600])) + + +def eins(pfad, **filter): + q = urllib.parse.urlencode(filter) + res = req("GET", "%s?%s" % (pfad, q)).get("results", []) + if len(res) > 1: + sys.exit("FEHLER: %s?%s liefert %d Treffer, erwartet 0 oder 1" % (pfad, q, len(res))) + return res[0] if res else None + + +def main(): + if not TOKEN: + sys.exit("NAUTOBOT_TOKEN fehlt.") + print("Modus:", "APPLY" if APPLY else "DRY-RUN (nichts wird geschrieben)") + + plat = eins("/dcim/platforms/", name=PLATTFORM) + if not plat: + sys.exit("Plattform %r nicht gefunden." % PLATTFORM) + print("Plattform : %s (%s)" % (plat["display"], plat["id"])) + + feat = eins("/plugins/golden-config/compliance-feature/", name=FEATURE) + if feat: + print("Feature : existiert bereits (%s)" % feat["id"]) + else: + print("Feature : anlegen -> %s" % FEATURE) + if APPLY: + feat = req("POST", "/plugins/golden-config/compliance-feature/", + {"name": FEATURE, "slug": FEATURE, "description": BESCHREIBUNG}) + print(" angelegt (%s)" % feat["id"]) + + if not feat: + print("\nDry-Run: ohne Feature kann die Rule nicht geprueft werden. Mit --apply erneut.") + return + + rule = eins("/plugins/golden-config/compliance-rule/", + feature=feat["id"], platform=plat["id"]) + if rule: + print("Rule : existiert bereits (%s), match_config=%r, config_ordered=%s" + % (rule["id"], rule.get("match_config"), rule.get("config_ordered"))) + if rule.get("match_config") != MATCH: + print(" ⚠️ match_config weicht ab -- erwartet %r" % MATCH) + if rule.get("config_ordered"): + print(" ⚠️ config_ordered=True -- erwartet False (s. u.)") + return + + nutzlast = { + "feature": feat["id"], + "platform": plat["id"], + "description": BESCHREIBUNG, + # 🔴 False, NICHT True: die Reservierungs-Sektion steht am Geraet in + # Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet), nicht numerisch. + # Bei config_ordered=True waere jede Umsortierung durch RouterOS ein + # Falsch-Drift. Die Reihenfolge ist hier auch semantisch bedeutungslos — + # anders als bei /ip firewall filter, wo sie ueber Gewinnen/Verlieren entscheidet. + "config_ordered": False, + "config_type": "cli", + "match_config": MATCH, + "custom_compliance": False, + "config_remediation": False, + } + print("Rule : anlegen -> %s auf %s, match_config=%r, config_ordered=False" + % (FEATURE, PLATTFORM, MATCH)) + if APPLY: + r = req("POST", "/plugins/golden-config/compliance-rule/", nutzlast) + print(" angelegt (%s)" % r["id"]) + else: + print(" Nutzlast: %s" % json.dumps(nutzlast)) + + +if __name__ == "__main__": + main() diff --git a/nautobot/mikrotik_routeros.j2 b/nautobot/mikrotik_routeros.j2 index 3c1562a..74b2c99 100644 --- a/nautobot/mikrotik_routeros.j2 +++ b/nautobot/mikrotik_routeros.j2 @@ -138,6 +138,25 @@ {% endfor %} /ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101 +# [5b] DHCP-RESERVIERUNGEN — kommen als DATEN aus local_config_context_data.dhcp_leases, +# wie firewall_filter. Warum das noetig war: bis 2026-09-21 rendert dieses Template +# KEIN einziges `/ip dhcp-server lease`. Die 54 Reservierungen stammen aus dem +# Einmal-Skript routeros/tp21-core-dhcp-from-ap1.rsc (Core-Cutover 2026-08-06) und +# standen damit in KEINEM Soll — ein GC-Recovery von null haette einen Core ohne +# Reservierungen erzeugt, in dem jedes IoT-Geraet in den Pool faellt. Und weil +# Compliance nur prueft, was gerendert wird, waere die Zahl dabei gruen geblieben. +# Dieselbe Klasse wie die handgelaufene `nautobot`-Adressliste (s. CLAUDE.md). +# +# 🔴 Zeilen sind RouterOS-/export-Form, alphabetisch, OHNE fuehrenden Pfad — genau +# wie firewall_filter. Gefuellt und verschoben von nautobot/tp45-leases.py. +# 🔴 Die REIHENFOLGE wird NICHT sortiert: das Backup traegt die Sektion in +# Einfuege-Reihenfolge (.30.150 frueh, .30.5 spaet), ein numerisch sortiertes +# Soll waere ein Dauer-Falsch-Drift. Die ComplianceRule steht deshalb auf +# config_ordered=no. +{% for line in obj.get_config_context().get("dhcp_leases", []) %} +/ip dhcp-server lease {{ line }} +{% endfor %} + # [6] DNS — DoH zum Cloudflare-Gateway, MIT Zertifikatspruefung (TP23 Stufe 1, # scharf seit 2026-08-18; schliesst TP21.1 TODO 1). # `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den diff --git a/nautobot/tp45-leases.py b/nautobot/tp45-leases.py new file mode 100644 index 0000000..37df004 --- /dev/null +++ b/nautobot/tp45-leases.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +r"""TP45 Schicht B — die DHCP-Reservierungen des Cores ins Soll heben. + +DIE LUECKE, DIE DAS SCHLIESST +----------------------------- +`nautobot/mikrotik_routeros.j2` rendert bis 2026-09-21 **kein einziges** +`/ip dhcp-server lease`. Die 54 statischen Reservierungen des Cores stammen aus dem +Einmal-Skript `routeros/tp21-core-dhcp-from-ap1.rsc` (Core-Cutover 2026-08-06) und +stehen in **keinem** Soll. + +Folge: ein GC-Recovery von null erzeugt einen Core ohne Reservierungen — jedes +IoT-Geraet faellt in den Pool, und weil Compliance nur prueft, was gerendert wird, +bliebe die Zahl dabei gruen. Dieselbe Klasse wie die `nautobot`-Adressliste +(s. CLAUDE.md): "was auf dem Geraet existiert, aber nicht im Template, ist eine +Recovery-Luecke". Zusaetzlich war keine einzige Reservierung drift-ueberwacht. + +DAS SOLL +-------- +Die Zeilen liegen wie `firewall_filter` als LISTE von Strings in +`local_config_context_data.dhcp_leases` auf `crs326-core` und werden vom Template +mit `/ip dhcp-server lease {{ line }}` gerendert. + +🔴 **Zeilenform ist die RouterOS-`/export`-Form, alphabetisch, ohne fuehrenden Pfad** +(genau wie `firewall_filter`): + add address=10.40.30.244 comment=NukiBridge mac-address=24:62:AB:B1:63:F4 server=dhcp-v30 + +🔴 **Die REIHENFOLGE wird erhalten, nicht sortiert.** Am Geraet steht die Sektion in +Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet) — `:export` gibt sie so aus, und +das Backup wird textuell verglichen. Ein numerisch sortiertes Soll waere ein +Dauer-Falsch-Drift. + +🔴 **Zeilen werden NICHT neu gebaut, sondern uebernommen und nur im `address=`-Token +geaendert.** Damit bleiben Quoting (`comment="P750W / P750W"` vs `comment=NukiBridge`) +und die 14 `client-id`-Felder automatisch korrekt — beides nachzubauen waere eine +Fehlerquelle ohne Nutzen. + +WARUM DER LESEWEG UEBER NAUTOBOT GEHT +------------------------------------- +Quelle fuer `seed` ist das GC-`backup_config` des Cores (taeglich 03:30 UTC bzw. +per `gc-configplan.py --refresh-compliance`). Kein Geraete-Credential noetig. +⚠️ `seed` ist damit ein RE-BASELINE und darf **nur** laufen, solange der Ist-Stand +der gewollte ist (Schicht B1). Fuer die geplante Aenderung gibt es `move`, das auf +dem **bestehenden Soll** rechnet — ein Soll, das dem Ist nachlaeuft, erkennt keine +Drift mehr (genau der Grund, warum `ingest-core-firewall.py` manuell bleibt). + + python3 nautobot/tp45-leases.py check # Soll vs. Backup-Diff + python3 nautobot/tp45-leases.py seed # Dry-Run + python3 nautobot/tp45-leases.py seed --apply # B1 + python3 nautobot/tp45-leases.py move TP45-B2-v30 # Dry-Run, s. MOVES + python3 nautobot/tp45-leases.py move TP45-B2-v30 --apply # B2 + +Token: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored), NAUTOBOT_SUPERUSER_API_TOKEN. +Der Wert wird nie ausgegeben. +""" +import json +import pathlib +import re +import sys +import urllib.error +import urllib.request + +NB = "http://10.40.20.154:8080" +DEVICE = "crs326-core" +KEY = "dhcp_leases" +PREFIX = "/ip dhcp-server lease " +REPO = pathlib.Path(__file__).resolve().parent.parent +YML = REPO / "docs/nas-vlan20/stacks/210-nautobot.yml" + +# --------------------------------------------------------------------------- +# Der Adressumzug (Schicht B2). Schluessel = MAC, Wert = (ist, soll). +# Quelle der Soll-Spalte: docs/teilprojekte.md ### TP45 / der Plan. +# Steht hier als Tabelle und nicht als Ableitung — der Umzug ist eine Absicht, +# kein Rechenergebnis. +# --------------------------------------------------------------------------- +MOVES = { + # fest verbaute Infrastruktur -> .10-.19 + "24:62:AB:B1:63:F4": ("10.40.30.244", "10.40.30.11"), # NukiBridge + "4C:EB:BD:E3:25:F2": ("10.40.30.5", "10.40.30.13"), # P750W (Etikettendrucker) + "54:F2:9F:12:51:F0": ("10.40.30.8", "10.40.30.14"), # DTUBI-414392337823 + # Shelly-Block .151-.176 (s1 -> .151 ... s26 -> .176) + "E4:B0:63:D7:C7:A4": ("10.40.30.208", "10.40.30.151"), # shelly-s1 + "28:37:2F:36:F4:7C": ("10.40.30.209", "10.40.30.152"), # shelly-s2 + "E4:B0:63:E4:EF:7C": ("10.40.30.210", "10.40.30.153"), # shelly-s3 + "E4:B0:63:E4:0F:30": ("10.40.30.211", "10.40.30.154"), # shelly-s4 + "D8:85:AC:0C:5C:C8": ("10.40.30.212", "10.40.30.155"), # shelly-s5 + "D0:CF:13:D8:18:FC": ("10.40.30.213", "10.40.30.156"), # shelly-s6 + "D0:CF:13:D9:94:E8": ("10.40.30.214", "10.40.30.157"), # shelly-s7 + "E4:B0:63:FA:FD:E0": ("10.40.30.215", "10.40.30.158"), # shelly-s8 + "E8:F6:0A:61:88:08": ("10.40.30.216", "10.40.30.159"), # shelly-s9 + "E4:B0:63:FC:57:E4": ("10.40.30.217", "10.40.30.160"), # shelly-s10 + "E4:B0:63:FA:6C:00": ("10.40.30.218", "10.40.30.161"), # shelly-s11 + "48:F6:EE:C8:E6:AC": ("10.40.30.219", "10.40.30.162"), # shelly-s12 + "48:F6:EE:DD:6A:F4": ("10.40.30.220", "10.40.30.163"), # shelly-s13 + "E4:B0:63:FC:59:48": ("10.40.30.221", "10.40.30.164"), # shelly-s14 + "E4:B0:63:FA:D5:34": ("10.40.30.222", "10.40.30.165"), # shelly-s15 + "10:20:BA:FA:F5:40": ("10.40.30.223", "10.40.30.166"), # shelly-s16 + "10:20:BA:FB:6D:64": ("10.40.30.224", "10.40.30.167"), # shelly-s17 + "48:F6:EE:CC:16:E4": ("10.40.30.225", "10.40.30.168"), # shelly-s18 + "10:20:BA:FB:70:FC": ("10.40.30.226", "10.40.30.169"), # shelly-s19 + "98:A3:16:7C:5F:EC": ("10.40.30.206", "10.40.30.170"), # shelly-s20 + "20:6E:F1:01:F9:F8": ("10.40.30.207", "10.40.30.171"), # shelly-s21 + "A0:85:E3:C8:65:34": ("10.40.30.202", "10.40.30.172"), # shelly-s22 + "AC:15:18:7B:62:20": ("10.40.30.203", "10.40.30.173"), # shelly-s23 + "14:33:5C:D8:2D:7C": ("10.40.30.204", "10.40.30.174"), # shelly-s24 + "98:A3:16:79:38:50": ("10.40.30.205", "10.40.30.175"), # shelly-s25 + "98:A3:16:7A:A3:54": ("10.40.30.233", "10.40.30.176"), # shelly-s26 (bad luefter) + # App-Block .177-.199 + "28:DB:A7:09:7B:54": ("10.40.30.241", "10.40.30.177"), # NukiSmartlock + "B8:F8:62:09:F2:04": ("10.40.30.227", "10.40.30.178"), # SwitchBot-floor-lamp + "98:F4:AB:2C:A7:88": ("10.40.30.228", "10.40.30.179"), # Twinkly-2CA789 + "A4:F0:0F:5F:D0:EC": ("10.40.30.236", "10.40.30.190"), # lichterkette + "24:DC:C3:9B:89:64": ("10.40.30.237", "10.40.30.191"), # lichterkette-mini + "00:24:E4:40:2A:72": ("10.40.30.230", "10.40.30.195"), # Withings_waage + "A4:7E:FA:18:2C:64": ("10.40.30.231", "10.40.30.196"), # Withings_blutdruck + # .150 (Ender3V3KE) und .180 (espressif/Airfryer) bleiben, wo sie sind. +} + +# CCU-Leiche: VLAN30-Reservierung `pve-homematic-raspi-ccu`, status=waiting, +# last-seen=never. Die CCU ist live in VLAN20 (10.40.20.232, ARP+HTTP belegt, +# Caddys ccu.bf proxyt dorthin) — dieser Eintrag ist ein Zombie aus dem +# Core-Cutover. Er wird ENTFERNT, nicht umgezogen. +REMOVE = {"BC:24:11:8E:0A:57"} + +# SONOFF-NSPanelPro hat KEINE Reservierung, nur eine dynamische Lease auf .249. +# Wird in B2 per make-static + set address gepinnt. Zeile hier nur als Soll. +ADDS = [ + "add address=10.40.30.12 client-id=1:7c:88:99:6:7:ec comment=SONOFF-NSPanelPro " + "mac-address=7C:88:99:06:07:EC server=dhcp-v30", +] + + +def token(): + for line in YML.read_text(errors="replace").splitlines(): + m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line) + if m: + return m.group(1) + raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml") + + +def nb(path, method="GET", payload=None): + req = urllib.request.Request( + NB + path, method=method, + data=json.dumps(payload).encode() if payload is not None else None, + headers={"Authorization": "Token " + token(), + "Content-Type": "application/json", "Accept": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + sys.exit("!! %s %s -> %s\n %s" % (method, path, e.code, e.read().decode()[:600])) + + +def device(): + return nb("/api/dcim/devices/?name=" + DEVICE)["results"][0] + + +def backup_lines(): + """Die `/ip dhcp-server lease`-Zeilen des Cores aus dem GC-Backup, in Geraete-Reihenfolge.""" + rows = nb("/api/plugins/golden-config/golden-config/?depth=1&limit=0")["results"] + for r in rows: + dev = r.get("device") + dev = dev.get("name") if isinstance(dev, dict) else dev + if dev == DEVICE: + cfg = r.get("backup_config") or "" + when = (r.get("backup_last_success_date") or "?")[:19] + return ([l[1:] for l in cfg.splitlines() if l.startswith(PREFIX)], when) + raise SystemExit("kein GC-Eintrag für " + DEVICE) + + +def mac_of(line): + m = re.search(r"\bmac-address=(\S+)", line) + return m.group(1).upper() if m else "" + + +def set_address(line, ip): + new, n = re.subn(r"(?<=^add address=)\S+", ip, line) + if n != 1: + raise SystemExit("address-Token nicht eindeutig in: " + line) + return new + + +def show(old, new): + print("== Diff (%d -> %d Zeilen) ==" % (len(old), len(new))) + om, nm = {mac_of(l): l for l in old}, {mac_of(l): l for l in new} + for mac in sorted(set(om) | set(nm)): + a, b = om.get(mac), nm.get(mac) + if a == b: + continue + if a is None: + print(" + %s" % b[:150]) + elif b is None: + print(" - %s" % a[:150]) + else: + print(" ~ %s -> %s" % (re.search(r"address=(\S+)", a).group(1), + re.search(r"address=(\S+)", b).group(1))) + print(" %s" % b[:150]) + + +def main(): + argv = sys.argv[1:] + if not argv or argv[0] not in ("seed", "move", "check"): + raise SystemExit(__doc__.strip().splitlines()[-2].strip()) + cmd, apply_ = argv[0], "--apply" in argv + dev = device() + ctx = dict(dev.get("local_config_context_data") or {}) + soll = list(ctx.get(KEY) or []) + + if cmd == "check": + ist, when = backup_lines() + print("== check: %s / %s (Backup %s, %d Zeilen) ==" % (DEVICE, KEY, when, len(ist))) + if not soll: + print(" Soll ist LEER — Reservierungen stehen in keinem Soll.") + return + show(soll, ist) + print(" (Diff oben: '-' = nur im Soll, '~' = Adresse weicht ab)") + return + + if cmd == "seed": + if soll: + raise SystemExit("Soll ist NICHT leer (%d Zeilen) — `seed` ist ein Re-Baseline. " + "Fuer eine geplante Aenderung `move` benutzen." % len(soll)) + new, when = backup_lines() + print("== seed aus Backup %s (%d Zeilen) ==" % (when, len(new))) + else: + if not soll: + raise SystemExit("Soll ist leer — erst `seed` laufen lassen (Schicht B1).") + new, unknown = [], set() + seen = set() + for line in soll: + mac = mac_of(line) + if mac in REMOVE: + seen.add(mac) + continue + if mac in MOVES: + ist_ip, soll_ip = MOVES[mac] + if re.search(r"address=(\S+)", line).group(1) != ist_ip: + raise SystemExit("%s: Ist-Adresse im Soll ist nicht %s — Soll und MOVES " + "auseinander. Zeile: %s" % (mac, ist_ip, line[:120])) + line = set_address(line, soll_ip) + seen.add(mac) + new.append(line) + for mac in list(MOVES) + list(REMOVE): + if mac not in seen: + unknown.add(mac) + if unknown: + raise SystemExit("MOVES/REMOVE nennen MACs, die im Soll fehlen: %s" + % ", ".join(sorted(unknown))) + for add in ADDS: + if mac_of(add) not in {mac_of(l) for l in new}: + new.append(add) + print("== move: %d Adressen verschoben, %d entfernt, %d ergaenzt ==" + % (len(MOVES), len(REMOVE), len(ADDS))) + + show(soll, new) + if not apply_: + print("\nDRY-RUN — mit --apply nach local_config_context_data.%s schreiben." % KEY) + return + ctx[KEY] = new + nb("/api/dcim/devices/%s/" % dev["id"], "PATCH", {"local_config_context_data": ctx}) + print("\n ~ %d Zeilen nach local_config_context_data.%s geschrieben." % (len(new), KEY)) + print(" Geraet zieht nach per: python3 nautobot/gc-configplan.py --refresh-compliance") + + +if __name__ == "__main__": + main()