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>
369 lines
16 KiB
Python
369 lines
16 KiB
Python
#!/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()
|