diff --git a/nautobot/gc-configplan.py b/nautobot/gc-configplan.py index 122ebd6..9e9f2aa 100644 --- a/nautobot/gc-configplan.py +++ b/nautobot/gc-configplan.py @@ -52,8 +52,11 @@ BENUTZUNG python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy """ import argparse +import importlib.util import json import os +import pathlib +import re import sys import time import urllib.error @@ -596,6 +599,130 @@ def job_tp45_pool_v20(): return [(["crs326-core"], _pool_set(20, "10.40.20.200-10.40.20.249"), "TP45-pool-v20")] +def _tp45_leases_module(): + """MOVES/REMOVE/ADDS aus `tp45-leases.py` laden. + + 🔴 **NICHT kopieren.** Diese Tabellen sind die SSoT des Umzugs. Eine zweite + Kopie hier liefe beim ersten Edit auseinander — und zwar still, weil beide + Seiten fuer sich plausibel aussehen; am Geraet faellt nur auf, dass eine + Adresse fehlt. + + Der Dateiname traegt einen Bindestrich (Repo-Konvention fuer Skripte), ist + also nicht regulaer importierbar -> importlib ueber den Pfad. `__name__` ist + dann nicht `__main__`, das `main()`-Guard der Datei greift also nicht. + """ + spec = importlib.util.spec_from_file_location( + "tp45_leases", pathlib.Path(__file__).with_name("tp45-leases.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _lease_move(mac, soll): + """Eine Umzugs-Zeile — EINZEILIG und klammer-vollstaendig (E1015-Lehre, s. _pool_set). + + Der Selektor wird GEZAEHLT: ein `find where`, das 0 trifft, sieht wie + „nicht vorhanden" aus und nicht wie „kaputt", und ein blankes `set [find …]` + liefe ins Leere, waehrend der Job Erfolg meldet. + + Absichtlich OHNE `address=` im Selektor: ein zweiter Lauf muss ein + No-Op sein, kein Fehler. Die Ist-Adresse wird stattdessen VOR dem Deploy + geprueft — `tp45-leases.py move` verlangt, dass das Soll genau `ist` traegt + und bricht sonst ab. + + 🔴🔴 **ZWEI VERSCHIEDENE Selektor-Formen, und das ist keine Kosmetik.** + Die erste Fassung schrieb ueberall die Argument-Form `[… [find where …]]`. + Die ist **Syntaxfehler**, sobald sie in `[:len …]` steht: + `expected command name (line 1 column 55)` (live gemessen 2026-09-21) + Der Deploy lief trotzdem als `Completed` durch, das Geraet blieb + **unveraendert**, und im Log stand nur die Schlusszeile — von den 38 + `:if`-Statements hat **kein einziger** Zweig geloggt, also ist auch kein + `set` gelaufen. + + * in `[:len …]` und direkt vor `find`: **`/ip dhcp-server lease find where …`** + (kein Klammerpaar um `find`) — Muster aus `_pool_set`/`job_tp45_agh_dhcp` + * als Argument einer Vollpfad-Kommandozeile: `set [find where …]` + — diese Form ist in `_pool_set` bewiesen und bleibt. + + ⚠️ **Merkregel:** `Completed` am Plan und `SUCCESS` am Job beweisen hier + nichts. Der Beweis ist die **Zahl der Log-Zeilen** (jeder Zweig loggt) oder + ein Geraete-Read. Beides gehoert zur Abnahme — genau wie beim + `pool-v20`-Deploy, der ohne Gegenprobe als gelungen gegolten haette. + """ + find = f'find where mac-address={mac}' + arg = f'[find where mac-address={mac}]' + return (f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ ' + f'/ip dhcp-server lease set {arg} address={soll}; ' + f':log info ("tp45-v30: {mac} -> {soll}") }} ' + f'else={{ :log info ("tp45-v30: {mac} NICHT EINDEUTIG -- nicht angefasst") }}') + + +def job_tp45_v30_leases(): + """TP45 Schicht B2 (2026-09-21): die VLAN30-Reservierungen ins Oktett-Schema. + + 36 Reservierungen wandern aus dem DHCP-Pool-Fenster (.202-.244, .5, .8) in die + Schema-Baender: `.11-.14` fest verbaute Infrastruktur, `.151-.176` Shelly-Block, + `.177-.196` App-Services. Dazu zwei Sonderfaelle: + + * `pve-homematic-raspi-ccu` (MAC BC:24:11:8E:0A:57) wird ENTFERNT — eine + Leiche aus dem Core-Cutover (`status=waiting`, `last-seen=never`). Die CCU + ist live in VLAN20 (.20.232, ARP + HTTP 200 belegt, Caddys `ccu.bf` proxyt + dorthin); die VLAN30-Reservierung ist ein Zombie. + * `SONOFF-NSPanelPro` hat GAR KEINE Reservierung, nur eine dynamische Lease + auf `.249`. Wird per `make-static` gepinnt und auf `.12` gezogen. + + ⚠️ **Reihenfolge:** dieser Job gehoert VOR `tp45-pool-v30`. Die Verengung auf + `.200-.249` setzt voraus, dass keine Reservierung mehr im Schema-Band liegt — + sonst zieht der Pool Adressen, die schon vergeben sind. + + ⚠️ **Kein Ausfall, kein Lockout:** ueber DHCP-Reservierungen umzuziehen heisst, + dass die Geraete beim naechsten Lease-Renew selbst nachziehen (lease-time 1d). + Nur zwei der 36 sind im Moment ueberhaupt `bound` (SwitchBot, DTUBI); alle + anderen stehen auf `last-seen=never`. Nichts wird hart umgeschaltet. + + ⚠️ **`.150` (Ender3V3KE) und `.180` (espressif/Airfryer) bleiben, wo sie sind** — + sie liegen bereits im App-Band und werden bewusst nicht bewegt. + + Rueckweg: `MOVES` traegt Ist UND Soll je MAC, das inverse `set` ist also + mechanisch; zusaetzlich liegt vor dem Deploy ein binaeres Backup auf dem Core. + """ + mod = _tp45_leases_module() + lines = [_lease_move(mac, soll) for mac, (_ist, soll) in sorted(mod.MOVES.items())] + + for mac in sorted(mod.REMOVE): + find = f'find where mac-address={mac}' + arg = f'[find where mac-address={mac}]' + lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ ' + f'/ip dhcp-server lease remove {arg}; ' + f':log info ("tp45-v30: CCU-Leiche {mac} entfernt") }} ' + f'else={{ :log info ("tp45-v30: CCU-Leiche schon weg") }}') + + for add in mod.ADDS: + mac = re.search(r"mac-address=(\S+)", add).group(1) + ip = re.search(r"address=(\S+)", add).group(1) + cmt = re.search(r"comment=(\S+)", add).group(1) + find = f'find where mac-address={mac}' + arg = f'[find where mac-address={mac}]' + # make-static VOR set: auf einer dynamischen Lease ist `set address=` wirkungslos + # bzw. wird beim naechsten Renew ohnehin verworfen -- erst statisch, dann pinnen. + lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ ' + f'/ip dhcp-server lease make-static {arg}; ' + f'/ip dhcp-server lease set {arg} address={ip} comment="{cmt}"; ' + f':log info ("tp45-v30: NSPanelPro statisch auf {ip}") }} ' + f'else={{ :log info ("tp45-v30: NSPanelPro hat keine Lease -- spaeter nachziehen") }}') + + # Abnahme-Zeile. `address=` ist ein echter Eigenschaftsvergleich (kein Regex) -- + # bewusst NICHT `~"10.40.30.11"`, das traefe auch .110-.119. + lines.append( + ':log info ("tp45-v30: .11=" . [:tostr [:len [/ip dhcp-server lease ' + 'find where address=10.40.30.11]]] . " .12=" . [:tostr [:len [/ip dhcp-server ' + 'lease find where address=10.40.30.12]]] . " .151=" . [:tostr [:len ' + '[/ip dhcp-server lease find where address=10.40.30.151]]] . " .196=" . ' + '[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.196]]])') + + return [(["crs326-core"], "\n".join(lines), "TP45-v30-leases")] + + JOBS = { "drift-cleanup": job_drift_cleanup, "mgmt-acl": job_mgmt_acl, @@ -604,6 +731,7 @@ JOBS = { "tp45-agh-fw": job_tp45_agh_fw, "tp45-agh-dhcp": job_tp45_agh_dhcp, "tp45-pool-v20": job_tp45_pool_v20, + "tp45-v30-leases": job_tp45_v30_leases, }