Initial import from bf-network-r6.opencode — opencode migration
This commit is contained in:
344
nautobot/gc-transaktion.py
Normal file
344
nautobot/gc-transaktion.py
Normal file
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Testaenderungen als Transaktion fuehren: begin / status / commit / rollback.
|
||||
|
||||
Das Problem, das dieses Skript loest
|
||||
------------------------------------
|
||||
Eine Aenderung, die am Geraet steht, aber nicht in der SoT, ist heute schlicht
|
||||
**Drift** — und Drift sieht immer gleich aus, egal ob er ein Versehen ist oder ein
|
||||
laufender Versuch. Wer testen will, hat also die Wahl zwischen "Compliance kaputt"
|
||||
und "gar nicht testen". Beides ist falsch.
|
||||
|
||||
Die Uebersetzung von BEGIN/COMMIT/ROLLBACK auf diesen Stack:
|
||||
|
||||
BEGIN Snapshot des Geraets + Akte mit Geraet, Scope und ABLAUFDATUM.
|
||||
Die Aenderung wird ganz normal per gc-manual-plan.py deployt und
|
||||
ist am Geraet sofort wirksam — nur eben nicht in der SoT.
|
||||
offen Compliance meldet den Drift weiter, aber `status` ordnet ihn zu:
|
||||
"erwartet (offene Transaktion)" statt "unerklaert".
|
||||
COMMIT Erst das Template/den Config-Context nachziehen, dann committen.
|
||||
Das Skript verweigert den Commit, solange der Scope noch driftet —
|
||||
denn dann waere die SoT gar nicht nachgezogen worden.
|
||||
ROLLBACK Erzeugt aus dem Snapshot einen Rueckbau-Vorschlag fuer den Scope.
|
||||
Bewusst NICHT automatisch gepusht: ein Rueckbau, den niemand
|
||||
gelesen hat, ist genau die Sorte Automatik, die dieses Projekt
|
||||
schon zweimal teuer bezahlt hat.
|
||||
|
||||
Drei Regeln, fest eingebaut
|
||||
---------------------------
|
||||
1. **Ablaufdatum ist Pflicht.** Eine offene Transaktion ohne Frist ist nichts
|
||||
anderes als undokumentierter Drift, nur mit besserem Gewissen.
|
||||
2. **Der Scope ist eine ComplianceRule**, kein Freibrief. `--scope bridge-vlan`
|
||||
heisst: Abweichungen in `/interface bridge vlan` sind erwartet, alles andere
|
||||
bleibt scharf.
|
||||
3. **Eine offene Transaktion je Geraet.** Sonst ist beim Rollback nicht mehr
|
||||
entscheidbar, wohin zurueck.
|
||||
|
||||
Aufruf
|
||||
------
|
||||
python3 nautobot/gc-transaktion.py begin --device crs326-core \
|
||||
--scope bridge-vlan --change-id TEST-ether3 --expires 2026-08-28 \
|
||||
--reason "ap1 testweise am Sideboard, Uplink auf ether3"
|
||||
python3 nautobot/gc-transaktion.py status
|
||||
python3 nautobot/gc-transaktion.py commit --change-id TEST-ether3
|
||||
python3 nautobot/gc-transaktion.py rollback --change-id TEST-ether3
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(HIER)
|
||||
AKTEN = os.path.join(REPO, "docs", "transaktionen")
|
||||
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
|
||||
PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
|
||||
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS"}
|
||||
IPS = {"crs326-core": "10.40.99.1", "crs326-branch": "10.40.99.2",
|
||||
"ap1": "10.40.99.11", "ap2": "10.40.99.12"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Nautobot
|
||||
def token():
|
||||
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN=([A-Za-z0-9]+)", open(STACK).read())
|
||||
if not m:
|
||||
sys.exit("Token nicht in %s gefunden." % STACK)
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def nb_get(pfad):
|
||||
req = urllib.request.Request(NB + pfad, headers={
|
||||
"Authorization": "Token " + token(), "Accept": "application/json"})
|
||||
return json.load(urllib.request.urlopen(req, timeout=30))
|
||||
|
||||
|
||||
def regeln():
|
||||
"""ComplianceRule-Name -> match_config (die Abschnitte, die sie prueft)."""
|
||||
d = nb_get("/api/plugins/golden-config/compliance-rule/?limit=100&depth=1")
|
||||
out = {}
|
||||
for r in d["results"]:
|
||||
feat = r.get("feature") or {}
|
||||
name = feat.get("name") or feat.get("display")
|
||||
out[name] = [z.strip() for z in (r.get("match_config") or "").splitlines() if z.strip()]
|
||||
return out
|
||||
|
||||
|
||||
def compliance(device):
|
||||
"""Feature -> compliant? fuer ein Geraet."""
|
||||
d = nb_get("/api/plugins/golden-config/config-compliance/?limit=200&depth=1"
|
||||
"&device=%s" % device)
|
||||
# 🔴 Der Feature-Name steht bei depth=1 NICHT ausgeschrieben in rule.feature — dort
|
||||
# kommt nur eine URL. Verwendbar ist rule.display ("RouterOS - bridge-vlan");
|
||||
# der Teil nach dem letzten " - " ist der Regelname. Ohne das landete jeder Drift
|
||||
# als "ausserhalb des Scopes" im Bericht, obwohl er genau der Scope war.
|
||||
out = {}
|
||||
for r in d["results"]:
|
||||
rule = r.get("rule") or {}
|
||||
anzeige = rule.get("display") if isinstance(rule, dict) else str(rule)
|
||||
name = (anzeige or "").split(" - ")[-1].strip()
|
||||
out[name] = bool(r.get("compliance"))
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Geraet
|
||||
def geheim(device):
|
||||
env = PW_ENV.get(device)
|
||||
if not env:
|
||||
sys.exit("Kein Passwort-Env fuer %r bekannt." % device)
|
||||
wert = os.environ.get(env)
|
||||
if wert:
|
||||
return wert
|
||||
pfad = os.path.expanduser("~/.secrets/credentials.env")
|
||||
m = re.search(r"^%s=(.*)$" % env, open(pfad).read(), re.M)
|
||||
if not m:
|
||||
sys.exit("$%s weder im Environment noch in %s." % (env, pfad))
|
||||
return m.group(1).strip().strip("\"'")
|
||||
|
||||
|
||||
def export(device):
|
||||
"""/export vom Geraet holen — der Snapshot, gegen den spaeter zurueckgebaut wird."""
|
||||
ip = IPS.get(device) or sys.exit("Keine IP fuer %r." % device)
|
||||
p = subprocess.run(["sshpass", "-p", geheim(device), "ssh",
|
||||
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
||||
"admin@" + ip, "/export"],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
if p.returncode != 0:
|
||||
sys.exit("Export fehlgeschlagen (rc=%d). %s" % (p.returncode, p.stderr[:200]))
|
||||
return p.stdout
|
||||
|
||||
|
||||
def abschnitt(text, praefixe):
|
||||
"""Die Zeilen aus einem /export, die zu den match_config-Praefixen gehoeren.
|
||||
|
||||
RouterOS-Exporte schreiben die Sektion einmal als Kopfzeile und darunter die
|
||||
Eintraege ohne Pfad — beides muss mit.
|
||||
"""
|
||||
raus, aktiv = [], False
|
||||
for zeile in text.splitlines():
|
||||
s = zeile.strip()
|
||||
if s.startswith("/"):
|
||||
aktiv = any(s.startswith(p) for p in praefixe)
|
||||
if aktiv:
|
||||
raus.append(s)
|
||||
continue
|
||||
if aktiv and s and not s.startswith("#"):
|
||||
raus.append(s)
|
||||
return raus
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Akten
|
||||
def akte_pfad(change_id):
|
||||
return os.path.join(AKTEN, "%s.json" % change_id)
|
||||
|
||||
|
||||
def akten():
|
||||
if not os.path.isdir(AKTEN):
|
||||
return []
|
||||
out = []
|
||||
for n in sorted(os.listdir(AKTEN)):
|
||||
if n.endswith(".json"):
|
||||
out.append(json.load(open(os.path.join(AKTEN, n))))
|
||||
return out
|
||||
|
||||
|
||||
def schreibe(akte):
|
||||
os.makedirs(AKTEN, exist_ok=True)
|
||||
with open(akte_pfad(akte["change_id"]), "w") as fh:
|
||||
json.dump(akte, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
|
||||
|
||||
def heute():
|
||||
return datetime.date.today().isoformat()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Befehle
|
||||
def cmd_begin(a):
|
||||
if os.path.exists(akte_pfad(a.change_id)):
|
||||
sys.exit("Es gibt bereits eine Akte %s." % a.change_id)
|
||||
offen = [t for t in akten() if t["state"] == "open" and t["device"] == a.device]
|
||||
if offen:
|
||||
sys.exit("Auf %s ist bereits %r offen — erst commit oder rollback.\n"
|
||||
"(Regel 3: eine offene Transaktion je Geraet.)"
|
||||
% (a.device, offen[0]["change_id"]))
|
||||
verfuegbar = regeln()
|
||||
if a.scope not in verfuegbar:
|
||||
sys.exit("Scope %r ist keine ComplianceRule. Bekannt sind:\n %s"
|
||||
% (a.scope, "\n ".join(sorted(verfuegbar))))
|
||||
if a.expires <= heute():
|
||||
sys.exit("--expires muss in der Zukunft liegen (Regel 1).")
|
||||
|
||||
os.makedirs(AKTEN, exist_ok=True)
|
||||
snap = os.path.join(AKTEN, "%s.snapshot.rsc" % a.change_id)
|
||||
# 🔴 NUR den Scope-Abschnitt sichern, nicht den ganzen /export. Der Vollexport
|
||||
# enthaelt Firewall, Adressen, Benutzerliste und Skriptquellen — nichts davon
|
||||
# wird fuer den Rueckbau gebraucht, und die Akten liegen im Git. Fuer den
|
||||
# Ernstfall gibt es das binaere /system backup auf dem Geraet (--device-backup).
|
||||
zeilen = abschnitt(export(a.device), verfuegbar[a.scope])
|
||||
with open(snap, "w") as fh:
|
||||
fh.write("# Snapshot des Scopes %r auf %s, %s (Transaktion %s)\n"
|
||||
"# Bewusst NUR dieser Abschnitt — der Vollexport gehoert nicht ins Repo.\n\n"
|
||||
% (a.scope, a.device, heute(), a.change_id))
|
||||
fh.write("\n".join(zeilen) + "\n")
|
||||
akte = {
|
||||
"change_id": a.change_id, "device": a.device, "scope": a.scope,
|
||||
"match_config": verfuegbar[a.scope], "reason": a.reason,
|
||||
"started": heute(), "expires": a.expires, "state": "open",
|
||||
"snapshot": os.path.relpath(snap, REPO),
|
||||
"device_backup": a.device_backup or "",
|
||||
}
|
||||
schreibe(akte)
|
||||
print("BEGIN %s auf %s — Scope %s, laeuft ab am %s"
|
||||
% (a.change_id, a.device, a.scope, a.expires))
|
||||
print(" Snapshot: %s" % akte["snapshot"])
|
||||
print("\nJetzt die Aenderung wie gewohnt deployen:")
|
||||
print(" python3 nautobot/gc-manual-plan.py --device %s --commands-file <datei.rsc> \\"
|
||||
% a.device)
|
||||
print(" --change-id %s --deploy --assert ..." % a.change_id)
|
||||
|
||||
|
||||
def cmd_status(a):
|
||||
ts = akten()
|
||||
if not ts:
|
||||
print("Keine Transaktionen.")
|
||||
return
|
||||
for t in ts:
|
||||
marke = {"open": "OFFEN", "committed": "committed", "rolledback": "zurueckgebaut"}[t["state"]]
|
||||
ueberfaellig = t["state"] == "open" and t["expires"] < heute()
|
||||
print("%-34s %-12s %-14s Scope %s%s"
|
||||
% (t["change_id"], marke, t["device"], t["scope"],
|
||||
" ⚠️ ABGELAUFEN am " + t["expires"] if ueberfaellig else ""))
|
||||
if t.get("reason"):
|
||||
print(" %s" % t["reason"])
|
||||
if t["state"] != "open":
|
||||
continue
|
||||
try:
|
||||
c = compliance(t["device"])
|
||||
except Exception as e:
|
||||
print(" (Compliance nicht lesbar: %s)" % e)
|
||||
continue
|
||||
drifts = [f for f, ok in c.items() if not ok]
|
||||
erwartet = [f for f in drifts if f == t["scope"]]
|
||||
fremd = [f for f in drifts if f != t["scope"]]
|
||||
print(" Drift im Scope: %s" % (", ".join(erwartet) or "keiner — SoT und Geraet gleich"))
|
||||
if fremd:
|
||||
print(" ⚠️ Drift AUSSERHALB des Scopes: %s" % ", ".join(fremd))
|
||||
print(" Das deckt diese Transaktion NICHT ab.")
|
||||
|
||||
|
||||
def cmd_commit(a):
|
||||
pfad = akte_pfad(a.change_id)
|
||||
if not os.path.exists(pfad):
|
||||
sys.exit("Keine Akte %s." % a.change_id)
|
||||
t = json.load(open(pfad))
|
||||
if t["state"] != "open":
|
||||
sys.exit("%s ist bereits %s." % (a.change_id, t["state"]))
|
||||
c = compliance(t["device"])
|
||||
if not c.get(t["scope"], True):
|
||||
sys.exit("COMMIT verweigert: %s driftet auf %s noch.\n"
|
||||
"Das heisst, die SoT ist nicht nachgezogen — erst Template/Config-Context\n"
|
||||
"aendern, dann `gc-configplan.py --refresh-compliance`, dann committen."
|
||||
% (t["scope"], t["device"]))
|
||||
t["state"] = "committed"
|
||||
t["closed"] = heute()
|
||||
schreibe(t)
|
||||
print("COMMIT %s — %s ist auf %s compliant, die Aenderung steht jetzt in der SoT."
|
||||
% (a.change_id, t["scope"], t["device"]))
|
||||
|
||||
|
||||
def cmd_rollback(a):
|
||||
pfad = akte_pfad(a.change_id)
|
||||
if not os.path.exists(pfad):
|
||||
sys.exit("Keine Akte %s." % a.change_id)
|
||||
t = json.load(open(pfad))
|
||||
if t["state"] != "open":
|
||||
sys.exit("%s ist bereits %s." % (a.change_id, t["state"]))
|
||||
|
||||
alt = abschnitt(open(os.path.join(REPO, t["snapshot"])).read(), t["match_config"])
|
||||
neu = abschnitt(export(t["device"]), t["match_config"])
|
||||
ziel = os.path.join(AKTEN, "%s.rollback.rsc" % a.change_id)
|
||||
with open(ziel, "w") as fh:
|
||||
fh.write("# Rueckbau-VORSCHLAG fuer %s (%s, Scope %s)\n"
|
||||
"# Aus dem Snapshot vom %s. NICHT ungeprueft deployen:\n"
|
||||
"# `add`-Zeilen legen neu an, statt zu aendern — was hier gebraucht wird,\n"
|
||||
"# ist in der Regel ein `set [find ...]`. Zeilen entsprechend anpassen.\n\n"
|
||||
% (a.change_id, t["device"], t["scope"], t["started"]))
|
||||
fh.write("\n".join(alt) + "\n")
|
||||
|
||||
print("Snapshot (%d Zeilen) gegen Ist-Stand (%d Zeilen) im Scope %s:\n"
|
||||
% (len(alt), len(neu), t["scope"]))
|
||||
for z in sorted(set(neu) - set(alt)):
|
||||
print(" JETZT NUR AM GERAET %s" % z)
|
||||
for z in sorted(set(alt) - set(neu)):
|
||||
print(" IM SNAPSHOT, FEHLT %s" % z)
|
||||
print("\nVorschlag geschrieben: %s" % os.path.relpath(ziel, REPO))
|
||||
print("Pruefen, anpassen, dann deployen:")
|
||||
print(" python3 nautobot/gc-manual-plan.py --device %s --commands-file %s \\"
|
||||
% (t["device"], os.path.relpath(ziel, REPO)))
|
||||
print(" --change-id %s-rollback --deploy --assert ..." % a.change_id)
|
||||
print("\nDanach die Akte schliessen: %s rollback --change-id %s --erledigt"
|
||||
% (os.path.basename(__file__), a.change_id))
|
||||
if a.erledigt:
|
||||
t["state"] = "rolledback"
|
||||
t["closed"] = heute()
|
||||
schreibe(t)
|
||||
print("\nAkte %s geschlossen (zurueckgebaut)." % a.change_id)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
b = sub.add_parser("begin", help="Transaktion oeffnen (Snapshot + Akte)")
|
||||
b.add_argument("--device", required=True)
|
||||
b.add_argument("--scope", required=True, help="Name einer ComplianceRule, z.B. bridge-vlan")
|
||||
b.add_argument("--change-id", required=True)
|
||||
b.add_argument("--expires", required=True, help="YYYY-MM-DD, Pflicht")
|
||||
b.add_argument("--reason", default="")
|
||||
b.add_argument("--device-backup", default="", help="Name eines vorher angelegten /system backup")
|
||||
b.set_defaults(func=cmd_begin)
|
||||
|
||||
s = sub.add_parser("status", help="offene Transaktionen und ihre Drifts")
|
||||
s.set_defaults(func=cmd_status)
|
||||
|
||||
c = sub.add_parser("commit", help="Aenderung in die SoT uebernommen -> Akte schliessen")
|
||||
c.add_argument("--change-id", required=True)
|
||||
c.set_defaults(func=cmd_commit)
|
||||
|
||||
r = sub.add_parser("rollback", help="Rueckbau-Vorschlag aus dem Snapshot erzeugen")
|
||||
r.add_argument("--change-id", required=True)
|
||||
r.add_argument("--erledigt", action="store_true", help="Akte schliessen (nach dem Deploy)")
|
||||
r.set_defaults(func=cmd_rollback)
|
||||
|
||||
a = p.parse_args()
|
||||
a.func(a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user