Compliance 68/68 + TP24 (USV) ausgeloest + Doku auf den Stand nach dem Core-Cutover
Zwei Straenge, die sich dieselben Dateien teilen — deshalb ein Commit. 1) COMPLIANCE-DRIFTS BESEITIGT (User: "beseitige autonom alle") -> 68/68, 0 Drifts Entfernt wurden die 4 verbliebenen `extra`-Drifts (der fuenfte, core 10.40.41.211, war schon ueber TP19-vlan1-core-address gefallen): - ap1 + ap2: Werks-defconf `/ip dns static router.lan -> 192.168.88.1`. Auf beiden APs der einzige statische Eintrag, die IP existiert im Netz nicht. Der Core hat sein eigenes, korrektes router.lan -> 10.40.99.1 (im Template, compliant). - crs326-branch: Bring-up-Rest SVI vlan10 + 10.40.10.2, Adresse zuerst. Richtung bewusst "am Geraet entfernen" statt "ins Template aufnehmen" — genau so steht es seit 2026-08-13 im Switch-Zweig des Templates. Der frueher notierte Vorbehalt "potenzielle Rettungspfade" ist per User-Entscheid aufgehoben; der Mgmt-Fallback aus VLAN10 auf dem branch entfaellt damit (Rueckweg = MAC-Winbox am selben Draht). Weg: 3 manuelle GC-ConfigPlans, kein Direkt-Write. Vorher /system backup je Geraet. Deploy gestaffelt — erst die APs, am Geraet gegengeprueft, dann branch. Verifikation am GERAET, nicht am Job: DNS-Statics total=0 bei unveraenderten 28 bzw. 3 WLAN-Clients; branch nur noch vlan99 + 10.40.99.2/24; Ping vom Core 4/4. Entscheidende Gegenprobe: /interface bridge vlan/export byte-identisch — `bridge` steht dort statisch als tagged-Member von VLAN10, das Entfernen des SVI hat also weder Traffic-Wirkung noch neuen Drift. Danach Backup -> Intended -> Compliance, alle 4 Geraete mit frischem Success-Datum. Neue API-Falle, die erklaert warum es bisher kein Skript gab: POST /plugins/golden-config/config-plan/ ist 405 — das ViewSet ist read-only. ConfigPlans entstehen nur ueber den Job "Generate Config Plans" (plan_type=manual + commands-TextVar). plan_type=missing erzeugt ausserdem nur add-Zeilen, also muss alles Entfernende zwangslaeufig ein manueller Plan sein. Neu: nautobot/gc-configplan.py — loest Device-UUIDs einzeln per Name auf und assertet plan.device UND config_set gegen das Ziel (die alphabetische Sortier-Falle aus TP21.1 kann damit nicht wiederkommen), verweigert config_sets mit <<REDACTED>> (Regel 1) und print-Zeilen, kann gestaffelt deployen (--deploy-ids) und kapselt Backup -> Intended -> Compliance (--refresh-compliance). Dry-Run ist Default. 2) TP16.1: GATE OFFEN, Runbook ausfuehrungsreif, Governance-Ausnahme #4 Die Bedingung "gated bis TP21 den CRS326 abdeckt" ist seit der Adoption am 2026-08-13 erfuellt. §3 neu gemessen, §4.3 von Platzhalter auf echte Werte. Die letzte inhaltliche Unbekannte hat sich erledigt: "Rueckweg-Routing TBD" — der Core IST L3-Gateway aller VLANs, es braucht keine Zusatzroute. wg-hub liegt auf demselben Geraet und ist damit direktes Vorbild statt fernes Muster. Klarstellung, weil leicht zu verwechseln: das Mesh ersetzt die Tunnel NICHT. Es loest nur den privaten Zugang (10.40.0.0/16 per IP, inkl. ICMP); die oeffentlichen FQDNs bleiben auf cloudflared. Relevant dabei: die Route 10.40.0.0/16 zeigt auf bf-pve — und der faehrt derzeit nur einen Connector. Ausnahme #4 (User 2026-08-16): die WG-Zeilen des Mesh-Bring-ups auf crs326-core duerfen direkt gesetzt werden. Grund ist strukturell, nicht bequem — TP22-Regel 1 schliesst jede <<REDACTED>>-Sektion vom Push aus, ein Privkey ist ueber Golden Config gar nicht deploybar. Auflage: Backup vorher, intended config danach zurueck nach Nautobot. Scope: nur dieser Bring-up. Damit das ein einmaliger Bootstrap bleibt, ist das Secret-Handling-Design dokumentiert: ein privater Schluessel ist Geraete-Identitaet, keine Konfiguration (wie ein SSH-Hostkey). Der private Teil entsteht am Geraet und geht nie weg, nur der oeffentliche kommt in die SoT und wird in die Peer-Configs gerendert. Stufe 2 (nautobot-secrets-providers + Vault) lohnt bei 4 Geraeten und einem Key nicht. Nebenbefund: die WG-Peers fm/dw stehen mit hartkodierten public-keys im Jinja-Template — Public Keys sind Daten, keine Templatezeilen. 3) TP24 — USV aus TP15 herausgeloest (User-Entscheid) Die DC-USV war der letzte offene Rest in TP15 und hat mit Switching/Routing sachlich nichts zu tun (eigene Einkaufs-, Verkabelungs- und Elektrik-Logik). TP15 ist damit inhaltlich zu. TP24 traegt jetzt 48V-Bus, MeanWell/LiFePO4, Laufzeiten, DC-Eingangs-Tabelle und die 5 offenen Entscheidungen — darunter neu die Standortfrage, die sich durch den Core-Umzug ins UG neu stellt. 4) DOKU-AUFRAEUM-SESSION A-E abgearbeitet - Ist-Stand-Block neu geschrieben, live gegen die Geraete geprueft. Alte Fassung als ausklappbare Historie, ⚠️-Provisorium entfernt. - Reihenfolge-Abschnitt auf den echten Rest-Pfad TP5 -> TP6 eingedampft (Punkt 3 sagte noch "Jetzt: Omada-PVID-Fix"). - Stale ap1-als-Router-Aussagen in TP3/TP4/TP15/TP16.1/TP21/TP-R korrigiert — fortgeschrieben, nicht ueberschrieben. - CLAUDE.md: Mac mini .41.167 -> .20.167 (war stale, live per ARP bestaetigt), branch-Uplink "sw1 GE8" -> "core ether8", ALLNET-/Omada-Gotchas als Historie. - switch/ als Archiv gekennzeichnet (Geraet ausgebaut, Runner nicht ausfuehrbar). - omada-mcp + CF-Ingress live gegengeprueft: beides sauber, Punkt abgehakt. Nebenbefund, eingetragen aber nicht angefasst: das CF-Tunnel-HA ist degradiert (bf-nas und bf-pve mit je nur einem Connector), und vier tote Alt-Tunnel tragen VLAN1-Origins — darunter die Aufloesung des "unidentifizierten VLAN1-Geraets .41.133" aus TP19 (es ist das coolify-Origin). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
368
nautobot/gc-configplan.py
Normal file
368
nautobot/gc-configplan.py
Normal file
@@ -0,0 +1,368 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BF-Netzwerk -> Nautobot Golden Config: manuelle ConfigPlans anlegen und deployen.
|
||||
|
||||
WOFUER
|
||||
------
|
||||
Bis 2026-08-16 wurden alle 17 ConfigPlans dieses Projekts ad hoc per curl gebaut. Das
|
||||
hat zweimal wehgetan (s. GOVERNANCE unten), deshalb hier einmal sauber als Skript.
|
||||
|
||||
Ein "manual" ConfigPlan ist der einzige Weg, etwas vom Geraet zu ENTFERNEN:
|
||||
`plan_type=missing` erzeugt ausschliesslich `add`-Zeilen, `intended` waere ein
|
||||
Voll-Push (verboten, s. Regel 1). Alles, was Drift vom Typ `extra` beseitigt, ist
|
||||
also zwangslaeufig ein manueller Plan.
|
||||
|
||||
GOVERNANCE (docs/teilprojekte.md TP22 -- verbindlich, nicht optional)
|
||||
--------------------------------------------------------------------
|
||||
1. Plans NUR feature-/manual-scoped, NIEMALS "full intended". Ein Voll-Push
|
||||
schriebe woertlich `passphrase="<<REDACTED>>"` auf die APs (= alle WLAN-Clients
|
||||
raus) bzw. `private-key="<<REDACTED>>"` auf den Core (= WG-Hub tot).
|
||||
-> Dieses Skript weigert sich, ein config_set mit `<<REDACTED>>` zu senden.
|
||||
2. Das AP-Template wird NIE zeilenweise auf ein laufendes Geraet gepusht
|
||||
(setzt zwischendurch vlan-filtering=no -> reisst den Mgmt-Pfad ab).
|
||||
3. Vor jedem Push ein `/system backup` auf dem Zielgeraet.
|
||||
-> Dieses Skript prueft das nicht, es kann nur daran erinnern. Machen.
|
||||
|
||||
ZWEI TEUER BEZAHLTE LEHREN, DIE HIER FEST EINGEBAUT SIND
|
||||
--------------------------------------------------------
|
||||
* Nautobot-REST sortiert Sammelabfragen ALPHABETISCH. Am 2026-08-13 wurden
|
||||
4 Device-UUIDs positional zugeordnet -> der Deploy ging an ap1 statt an den Core.
|
||||
Schadlos nur, weil RouterOS die Kommandos ablehnte. -> `resolve_device()` fragt
|
||||
IMMER genau einen Namen ab und besteht auf genau einem Treffer; nach dem Anlegen
|
||||
wird `plan.device.display` gegen den Zielnamen assertet.
|
||||
* Job-Status ist KEIN Wirksamkeitsnachweis. Der Deploy-Job meldet `SUCCESS`, waehrend
|
||||
der Plan auf `Failed` steht -- und selbst ein `Completed`-Plan beweist nur, dass
|
||||
Netmiko die Zeilen abgesetzt hat, nicht dass das Geraet sie angenommen hat.
|
||||
-> Dieses Skript liest am Ende den PLAN-Status und sagt explizit, dass die
|
||||
Gegenprobe am Geraet noch aussteht.
|
||||
|
||||
WEITERE ROUTEROS-FALLEN (Memory `ref_gc_push_gotchas_routeros`)
|
||||
---------------------------------------------------------------
|
||||
* Keine `print`-Zeilen in Config Plans -- `:log info` verwenden.
|
||||
* Selbstprotokollierendes `:do { ... } on-error={ :log info "..." }` statt nackter
|
||||
Kommandos: ein Netmiko-Fehlschlag hinterlaesst sonst Teil-Zustand (anders als
|
||||
NAPALM, das atomar zurueckrollt).
|
||||
* `napalm-ros` ist READ-ONLY. Der Push laeuft nur, weil in den Golden-Config-
|
||||
Settings ein `custom_dispatcher` auf `NetmikoMikrotikRouteros` zeigt.
|
||||
|
||||
BENUTZUNG
|
||||
---------
|
||||
export NAUTOBOT_TOKEN=...
|
||||
python3 nautobot/gc-configplan.py --job drift-cleanup # Dry-Run
|
||||
python3 nautobot/gc-configplan.py --job drift-cleanup --apply # anlegen+deployen
|
||||
python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
|
||||
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen)
|
||||
STATUS_NOT_APPROVED = "4d2d08bf-3bf2-4370-8c8b-248dff2618ab"
|
||||
STATUS_APPROVED = "7869b4b9-542d-4ff7-a846-92a04943e75d"
|
||||
JOB_DEPLOY_CONFIG_PLANS = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa"
|
||||
JOB_GENERATE_CONFIG_PLANS = "f93bfa53-2332-4fa2-a6ab-5921c802fcee"
|
||||
|
||||
# Die Reihenfolge ist NICHT beliebig: die Compliance vergleicht die intended config
|
||||
# gegen das letzte BACKUP, nicht gegen das Live-Geraet. Wer nach einer Geraetaenderung
|
||||
# direkt "Compliance" laufen laesst, misst den Stand von vorgestern (TP22-Falle).
|
||||
REFRESH_SEQUENCE = [
|
||||
("Backup Configurations", "a48b8c7b-ad4b-4909-8344-1b4e758e75a8"),
|
||||
("Generate Intended Configurations", "afe60af2-bcd1-4166-8976-1a78f49bc228"),
|
||||
("Perform Configuration Compliance", "ed3bcb19-5c84-4ffd-a7ba-564703213997"),
|
||||
]
|
||||
|
||||
MASK = "<<REDACTED>>"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- HTTP
|
||||
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=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode()[:600]
|
||||
print(f"!! {method} {url}\n payload={json.dumps(data)[:300]}\n -> {e.code} {detail}")
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- Bausteine
|
||||
def resolve_device(name):
|
||||
"""Genau EIN Geraet per Name. Nie positional aus einer Sammelabfrage."""
|
||||
q = urllib.parse.urlencode({"name": name})
|
||||
res = req("GET", f"/dcim/devices/?{q}").get("results", [])
|
||||
if len(res) != 1:
|
||||
raise SystemExit(f"FEHLER: '{name}' liefert {len(res)} Treffer, erwartet genau 1")
|
||||
dev = res[0]
|
||||
if dev.get("name") != name:
|
||||
raise SystemExit(f"FEHLER: API lieferte '{dev.get('name')}' statt '{name}'")
|
||||
return dev["id"]
|
||||
|
||||
|
||||
def guard_config_set(config_set, device_name):
|
||||
"""Regel 1: nie eine Maske aufs Geraet schreiben."""
|
||||
if MASK in config_set:
|
||||
raise SystemExit(
|
||||
f"ABBRUCH: config_set fuer {device_name} enthaelt {MASK}. "
|
||||
"Das wuerde das echte Secret auf dem Geraet ueberschreiben (TP22-Regel 1)."
|
||||
)
|
||||
for line in config_set.splitlines():
|
||||
if line.strip().startswith("print") or " print " in f" {line} ":
|
||||
raise SystemExit(f"ABBRUCH: `print` in Config Plan ({device_name}) -- `:log info` verwenden.")
|
||||
|
||||
|
||||
def create_plans(device_names, config_set, change_control_id, dry):
|
||||
"""Plaene ueber den Job 'Generate Config Plans' anlegen.
|
||||
|
||||
WICHTIG: `POST /plugins/golden-config/config-plan/` ist **405 Method Not Allowed** --
|
||||
das ViewSet ist read-only. ConfigPlans entstehen ausschliesslich ueber diesen Job.
|
||||
Bei `plan_type=manual` landet der Inhalt des `commands`-TextVar als `config_set`
|
||||
auf jedem ausgewaehlten Geraet (ein Plan pro Geraet).
|
||||
"""
|
||||
guard_config_set(config_set, ",".join(device_names))
|
||||
dev_ids = [resolve_device(n) for n in device_names]
|
||||
|
||||
if dry:
|
||||
print(f" [dry-run] Job 'Generate Config Plans' fuer {', '.join(device_names)} ({change_control_id})")
|
||||
for line in config_set.strip().splitlines():
|
||||
print(f" | {line}")
|
||||
return []
|
||||
|
||||
run = req(
|
||||
"POST",
|
||||
f"/extras/jobs/{JOB_GENERATE_CONFIG_PLANS}/run/",
|
||||
{
|
||||
"data": {
|
||||
"device": dev_ids,
|
||||
"plan_type": "manual",
|
||||
"commands": config_set,
|
||||
"change_control_id": change_control_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
jr = (run.get("job_result") or run).get("id")
|
||||
wait_job(jr, "Generate Config Plans")
|
||||
|
||||
# Plaene ueber die change_control_id einsammeln -- nie positional aus einer
|
||||
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
|
||||
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
|
||||
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
|
||||
by_name = {}
|
||||
for p in found:
|
||||
name = (p.get("device") or {}).get("display")
|
||||
by_name[name] = p["id"]
|
||||
|
||||
plans = []
|
||||
for name in device_names:
|
||||
pid = by_name.get(name)
|
||||
if not pid:
|
||||
raise SystemExit(f"ABBRUCH: kein Plan fuer '{name}' unter ccid '{change_control_id}' gefunden")
|
||||
# Gegenprobe am Objekt selbst, nicht am Suchergebnis.
|
||||
check = req("GET", f"/plugins/golden-config/config-plan/{pid}/?depth=1")
|
||||
got = (check.get("device") or {}).get("display")
|
||||
if got != name:
|
||||
raise SystemExit(f"ABBRUCH: Plan {pid} haengt an '{got}', erwartet '{name}'. NICHT deployen.")
|
||||
if check.get("config_set", "").strip() != config_set.strip():
|
||||
raise SystemExit(f"ABBRUCH: config_set von Plan {pid} weicht ab -- nicht deployen.")
|
||||
print(f" Plan {pid} -> '{name}' verifiziert (Device + config_set)")
|
||||
plans.append((name, pid))
|
||||
return plans
|
||||
|
||||
|
||||
def wait_job(jr, label):
|
||||
print(f" Job '{label}' gestartet, JobResult {jr}")
|
||||
for _ in range(60):
|
||||
time.sleep(5)
|
||||
st = req("GET", f"/extras/job-results/{jr}/").get("status")
|
||||
st = st.get("value") if isinstance(st, dict) else st
|
||||
if str(st).upper() in ("SUCCESS", "FAILURE", "ERRORED", "REVOKED", "COMPLETED", "FAILED"):
|
||||
print(f" Job-Status: {st} (!! kein Wirksamkeitsnachweis)")
|
||||
return st
|
||||
print(" !! Job-Timeout nach 5 Min -- Status manuell pruefen")
|
||||
return None
|
||||
|
||||
|
||||
def approve(plan_id):
|
||||
req("PATCH", f"/plugins/golden-config/config-plan/{plan_id}/", {"status": STATUS_APPROVED})
|
||||
|
||||
|
||||
def deploy(plan_ids):
|
||||
"""Deploy-Job starten und auf das JobResult warten."""
|
||||
run = req(
|
||||
"POST",
|
||||
f"/extras/jobs/{JOB_DEPLOY_CONFIG_PLANS}/run/",
|
||||
{"data": {"config_plan": plan_ids}},
|
||||
)
|
||||
jr = (run.get("job_result") or run).get("id")
|
||||
wait_job(jr, "Deploy Config Plans")
|
||||
return jr
|
||||
|
||||
|
||||
def refresh_compliance():
|
||||
"""Backup -> Intended -> Compliance, strikt in dieser Reihenfolge, dann Bericht."""
|
||||
for label, job_id in REFRESH_SEQUENCE:
|
||||
print(f"\n-- {label}")
|
||||
run = req("POST", f"/extras/jobs/{job_id}/run/", {"data": {}})
|
||||
jr = (run.get("job_result") or run).get("id")
|
||||
wait_job(jr, label)
|
||||
|
||||
print("\n== Compliance ==")
|
||||
res = req("GET", "/plugins/golden-config/config-compliance/?limit=500&depth=1")
|
||||
rows = res.get("results", [])
|
||||
bad = [r for r in rows if not r.get("compliance")]
|
||||
print(f" {len(rows) - len(bad)}/{len(rows)} compliant")
|
||||
for r in bad:
|
||||
dev = (r.get("device") or {}).get("display")
|
||||
rule = r.get("rule")
|
||||
rule = rule.get("display") if isinstance(rule, dict) else rule
|
||||
print(f" !! {dev} | {rule}")
|
||||
if r.get("extra"):
|
||||
print(f" extra: {r['extra'].strip()[:200]}")
|
||||
if r.get("missing"):
|
||||
print(f" missing: {r['missing'].strip()[:200]}")
|
||||
if not bad:
|
||||
print(" keine Drifts.")
|
||||
return bad
|
||||
|
||||
|
||||
def plan_status(plan_id):
|
||||
p = req("GET", f"/plugins/golden-config/config-plan/{plan_id}/?depth=1")
|
||||
st = p.get("status")
|
||||
return st.get("display") if isinstance(st, dict) else st
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ Jobs
|
||||
def job_drift_cleanup():
|
||||
"""TP22-Compliance-Backlog: die 4 verbliebenen `extra`-Drifts entfernen.
|
||||
|
||||
ap1/ap2: Werks-`defconf` DNS-Static `router.lan -> 192.168.88.1`. Auf beiden APs
|
||||
der EINZIGE statische Eintrag; die IP existiert im Netz nicht. Der Core hat sein
|
||||
eigenes, korrektes `router.lan -> 10.40.99.1` (im Template, compliant).
|
||||
|
||||
crs326-branch: Bring-up-Rest `vlan10` + `10.40.10.2`. Reihenfolge Adresse ->
|
||||
Interface. Beruehrt KEINEN Client-Traffic: `/interface bridge vlan` bleibt
|
||||
unangetastet (`bridge` steht dort statisch als tagged-Member von VLAN10), es
|
||||
faellt nur die L3-Praesenz des Geraets in VLAN10 weg. Die Deploy-Session selbst
|
||||
laeuft ueber vlan99/10.40.99.2, also nicht ueber den abgebauten Pfad.
|
||||
"""
|
||||
ap_set = (
|
||||
':do { /ip dns static remove [find address=192.168.88.1] } '
|
||||
'on-error={ :log info "drift: ap dns-static remove hat geworfen" }\n'
|
||||
':log info ("drift: rest-defconf-static=" . '
|
||||
'[:tostr [:len [/ip dns static find address=192.168.88.1]]])'
|
||||
)
|
||||
branch_set = (
|
||||
':do { /ip address remove [find address="10.40.10.2/24"] } '
|
||||
'on-error={ :log info "drift: branch address remove hat geworfen" }\n'
|
||||
':do { /interface vlan remove [find name="vlan10"] } '
|
||||
'on-error={ :log info "drift: branch vlan10 remove hat geworfen" }\n'
|
||||
':log info ("drift: branch rest-vlan10=" . '
|
||||
'[:tostr [:len [/interface vlan find name="vlan10"]]] . " rest-addr=" . '
|
||||
'[:tostr [:len [/ip address find address~"10.40.10."]]])'
|
||||
)
|
||||
# (Geraeteliste, commands, change_control_id) -- ap1/ap2 teilen sich das
|
||||
# config_set, koennen also in einem Job-Lauf erzeugt werden.
|
||||
return [
|
||||
(["ap1", "ap2"], ap_set, "TP22-drift-aps-defconf-dns"),
|
||||
(["crs326-branch"], branch_set, "TP22-drift-branch-temp-vlan10"),
|
||||
]
|
||||
|
||||
|
||||
JOBS = {"drift-cleanup": job_drift_cleanup}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ main
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--job", choices=sorted(JOBS))
|
||||
ap.add_argument("--apply", action="store_true", help="schreiben (sonst Dry-Run)")
|
||||
ap.add_argument("--no-deploy", action="store_true", help="Plaene nur anlegen, nicht deployen")
|
||||
ap.add_argument(
|
||||
"--deploy-ids",
|
||||
nargs="+",
|
||||
metavar="PLAN_ID",
|
||||
help="bereits angelegte Plaene gestaffelt deployen (Risiko-Reihenfolge selbst waehlen)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--refresh-compliance",
|
||||
action="store_true",
|
||||
help="Backup -> Generate Intended -> Compliance (in dieser Reihenfolge) + Ergebnisbericht",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not TOKEN:
|
||||
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt")
|
||||
|
||||
# Gestaffelter Deploy bereits angelegter Plaene -- fuer Faelle, in denen man
|
||||
# das riskantere Geraet erst anfassen will, nachdem das harmlose verifiziert ist.
|
||||
if args.deploy_ids:
|
||||
for pid in args.deploy_ids:
|
||||
p = req("GET", f"/plugins/golden-config/config-plan/{pid}/?depth=1")
|
||||
print(f" {pid} -> {(p.get('device') or {}).get('display')} [{plan_status(pid)}]")
|
||||
deploy(args.deploy_ids)
|
||||
print("\n== Plan-Status (massgeblich, nicht der Job-Status) ==")
|
||||
for pid in args.deploy_ids:
|
||||
p = req("GET", f"/plugins/golden-config/config-plan/{pid}/?depth=1")
|
||||
print(f" {(p.get('device') or {}).get('display'):15s} {plan_status(pid)}")
|
||||
print("\nJetzt am GERAET gegenpruefen -- 'Completed' beweist nichts.")
|
||||
return
|
||||
|
||||
if args.refresh_compliance:
|
||||
refresh_compliance()
|
||||
return
|
||||
|
||||
if not args.job:
|
||||
raise SystemExit("--job, --deploy-ids oder --refresh-compliance angeben")
|
||||
|
||||
dry = not args.apply
|
||||
print(f"== {args.job} == {'DRY-RUN' if dry else 'APPLY'}")
|
||||
if not dry:
|
||||
print(" Erinnerung Regel 3: `/system backup` auf allen Zielgeraeten vorhanden?")
|
||||
|
||||
plans = []
|
||||
for device_names, config_set, ccid in JOBS[args.job]():
|
||||
print(f"\n-- {', '.join(device_names)} ({ccid})")
|
||||
plans.extend(create_plans(device_names, config_set, ccid, dry))
|
||||
|
||||
if dry or not plans:
|
||||
print("\nDry-Run beendet -- nichts geschrieben." if dry else "\nKeine Plaene angelegt.")
|
||||
return
|
||||
|
||||
for _, pid in plans:
|
||||
approve(pid)
|
||||
print(f"\n{len(plans)} Plaene auf 'Approved' gesetzt")
|
||||
|
||||
if args.no_deploy:
|
||||
print("--no-deploy: Deploy uebersprungen.")
|
||||
return
|
||||
|
||||
print("\n== Deploy ==")
|
||||
deploy([pid for _, pid in plans])
|
||||
|
||||
print("\n== Plan-Status (massgeblich, nicht der Job-Status) ==")
|
||||
for name, pid in plans:
|
||||
print(f" {name:15s} {plan_status(pid)} ({pid})")
|
||||
|
||||
print(
|
||||
"\nNOCH NICHT FERTIG:\n"
|
||||
" 1. Am GERAET gegenpruefen (MikroMCP) -- Plan 'Completed' beweist nichts.\n"
|
||||
" 2. Danach in dieser Reihenfolge: Backup -> Generate Intended -> Compliance.\n"
|
||||
" Die Compliance vergleicht gegen das letzte BACKUP, nicht gegen das Geraet."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user