#!/usr/bin/env python3 r"""Manuellen Golden-Config-Plan anlegen, freigeben und deployen. Der von der Governance (TP22) vorgeschriebene Weg fuer Geraeteaenderungen: feature-/manual-scoped, **nie** "full intended" — ein Voll-Push schriebe `<>`-Sektionen woertlich aufs Geraet (WLAN-PSKs, WG-Privkey). python3 nautobot/gc-manual-plan.py --device crs326-core \ --commands-file /pfad/zu/commands.rsc --change-id "TP5-Etappe1" --dry-run python3 nautobot/gc-manual-plan.py --device crs326-core \ --commands-file /pfad/zu/commands.rsc --change-id "TP5-Etappe1" --deploy Ablauf: Job "Generate Config Plans" (plan_type=manual) -> Plan auf Approved -> Job "Deploy Config Plans". Dazwischen wird jeweils auf den JobResult gewartet. ZWEI TP22-LEHREN SIND HIER EINGEBAUT ------------------------------------ 1. **Job-Status ist kein Wirksamkeitsnachweis.** Der Deploy-Job meldete schon SUCCESS, waehrend der Plan auf Failed stand. Das Skript liest darum nach dem Deploy den *Plan*-Status zurueck und richtet seinen Exit-Code danach. Die eigentliche Abnahme bleibt trotzdem die Gegenpruefung am Geraet. 2. **Geraete werden einzeln per Name aufgeloest**, nie positional aus einer Sammelabfrage. Nautobot sortiert Sammelabfragen alphabetisch; eine positionale Zuordnung hat einen Deploy schon mal ans falsche Geraet geschickt. DRITTE LEHRE (2026-08-17): --assert ----------------------------------- "Plan-Status: Completed" sagt nur, dass die Zeilen abgesetzt wurden — nicht, dass sie *gewirkt* haben. Am 2026-08-17 blieb ein `/interface/wifi/set ... !channel` wirkungslos, ohne zu werfen; das Selbstprotokoll im Plan meldete trotzdem Erfolg, weil es das **Profil** las, waehrend der **Interface-Inline-Override** gewann. `--assert` prueft den Effekt nach dem Deploy per RouterOS-REST **am Geraet** und bricht laut ab, wenn er nicht eintritt. Damit ist die Abnahme maschinell statt davon abhaengig, dass jemand daran denkt nachzusehen. --assert '/ip/firewall/filter|count=0' --assert '/interface/wifi?name=wifi1|channel.width=20/40mhz' --assert 'cmd:/interface/wifi/monitor?numbers=wifi1&once=|channel=5180/ax/Ce/I' Format: ``[cmd:][?query]|=`` — mehrfach angebbar. Ohne ``cmd:`` ein GET (Lesen), mit ``cmd:`` ein POST (RouterOS-Kommandos wie ``monitor``, die kein GET sind). Trenner ist ``|``, **nicht** ``:`` — MAC-Adressen in Werten enthalten Doppelpunkte. ⚠️ **Regel fuer Funkaenderungen:** die Assertion muss ``monitor`` lesen, NIE das Profil. RouterOS hat drei Schichten (channel-Profil -> configuration -> Inline am Interface); ``print detail`` zeigt aufgeloeste Werte, ``export`` die rohen, und nur ``monitor`` das, was tatsaechlich auf der Luft ist. Genau diese Verwechslung war der Fehler vom 2026-08-17. """ import argparse import base64 import json import os import pathlib import re import sys import time import urllib.error import urllib.parse import urllib.request NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080") GEN_JOB = "f93bfa53-2332-4fa2-a6ab-5921c802fcee" # Generate Config Plans DEPLOY_JOB = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa" # Deploy Config Plans def token(): t = os.environ.get("NAUTOBOT_TOKEN") if not t: raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt " "(steht in docs/nas-vlan20/stacks/210-nautobot.yml, gitignored)") return t def api(path, method="GET", payload=None): req = urllib.request.Request( f"{NB}{path}", data=json.dumps(payload).encode() if payload is not None else None, method=method, headers={"Authorization": f"Token {token()}", "Content-Type": "application/json", "Accept": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=60) as resp: raw = resp.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: sys.stderr.write(f"HTTP {e.code} auf {method} {path}\n{e.read().decode()[:2000]}\n") raise def resolve_device(name): """Einzeln per Name — siehe Kopfkommentar, Lehre 2.""" res = api(f"/api/dcim/devices/?name={name}")["results"] if len(res) != 1: raise SystemExit(f"Device {name!r}: {len(res)} Treffer statt genau 1") return res[0]["id"] def status_id(name): res = api(f"/api/extras/statuses/?content_types=nautobot_golden_config.configplan&name={name}")["results"] if len(res) != 1: raise SystemExit(f"ConfigPlan-Status {name!r}: {len(res)} Treffer statt genau 1") return res[0]["id"] def wait_job(job_result_id, label, timeout=900): """Pollt bis der JobResult terminal ist. Gibt den finalen Status zurueck.""" deadline = time.time() + timeout last = None while time.time() < deadline: jr = api(f"/api/extras/job-results/{job_result_id}/") st = (jr.get("status") or {}).get("value") or jr.get("status") if st != last: print(f" {label}: {st}") last = st if st in ("SUCCESS", "FAILURE", "ERRORED", "REVOKED"): return st time.sleep(5) raise SystemExit(f"{label}: Timeout nach {timeout}s") def job_log(job_result_id, levels=("error", "warning", "failure")): try: logs = api(f"/api/extras/job-log-entries/?job_result={job_result_id}&limit=200")["results"] except Exception: return for e in logs: if (e.get("log_level") or "").lower() in levels: print(f" [{e.get('log_level')}] {e.get('message')}") # --------------------------------------------------------------------------- # --assert: Wirksamkeitspruefung am Geraet (RouterOS REST) # --------------------------------------------------------------------------- # Ein Geraet = ein Passwort-Env. User ist auf allen vier MikroTik `admin` # (einheitliches Admin-PW, User-Vorgabe 2026-08-08). PASS_ENV = { "crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS", "ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS", } SECRETS_FILE = pathlib.Path.home() / ".secrets" / "credentials.env" def _from_secrets(key): """`credentials.env` ist eine NACHSCHLAGE-Datei, keine sourcebare: sie enthaelt u.a. WireGuard-`[Interface]`-Bloecke, an denen `set -a; . datei` in zsh abbricht — und zwar *stillschweigend* fuer alle Zeilen danach. Darum hier ein simpler KEY=VALUE-Parser statt Shell-Ausfuehrung.""" if not SECRETS_FILE.exists(): return None for line in SECRETS_FILE.read_text(errors="replace").splitlines(): line = line.strip() if line.startswith("export "): line = line[7:] k, sep, v = line.partition("=") if sep and k.strip() == key: return v.strip().strip("'\"") return None def device_credentials(name): env = PASS_ENV.get(name) if not env: raise SystemExit(f"--assert: kein Passwort-Env fuer {name!r} bekannt " f"(bekannt: {', '.join(sorted(PASS_ENV))})") pw = os.environ.get(env) or _from_secrets(env) if not pw: raise SystemExit(f"--assert: ${env} weder im Environment noch in " f"{SECRETS_FILE} gefunden") user = os.environ.get(f"{name.upper().replace('-', '_')}_USER", "admin") return user, pw def device_address(name): """Mgmt-IP aus Nautobot statt hartkodiert — eine Quelle der Wahrheit.""" dev = api(f"/api/dcim/devices/?name={name}&depth=1")["results"][0] ip = (dev.get("primary_ip4") or {}).get("address") if not ip: raise SystemExit(f"--assert: {name} hat keine primary_ip4 in Nautobot") return ip.split("/")[0] def _redact(text, secret): """Passwoerter nie in Fehlerausgaben (Vorgabe Christoph).""" return text.replace(secret, "<>") if secret else text def rest_call(name, path, query, post=False): host = device_address(name) user, pw = device_credentials(name) url = f"http://{host}/rest{path}" body = None if post: # ?a=b&c= -> {"a": "b", "c": ""} (RouterOS-Kommandos wie monitor) body = json.dumps({ k: v for k, _, v in (p.partition("=") for p in query.split("&") if p) }).encode() elif query: # Werte URL-encoden. Ein Kommentar wie "TP5-E5: MAIN->IOT" traegt ein # Leerzeichen; roh angehaengt wirft http.client InvalidURL ("URL can't # contain control characters") — der Deploy ist dann laengst durch und # nur die Gegenpruefung crasht, was schlimmer ist als kein Assert. # (2026-08-19 bei TP5-E5 aufgeschlagen.) Trennzeichen & und = bleiben. parts = [] for p_ in query.split("&"): if not p_: continue k, sep, v = p_.partition("=") parts.append(f"{urllib.parse.quote(k, safe='')}{sep}" f"{urllib.parse.quote(v, safe='')}" if sep else urllib.parse.quote(k, safe="")) url += "?" + "&".join(parts) auth = base64.b64encode(f"{user}:{pw}".encode()).decode() req = urllib.request.Request( url, data=body, method="POST" if post else "GET", headers={"Authorization": f"Basic {auth}", "Content-Type": "application/json", "Accept": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read() return json.loads(raw) if raw else [] except urllib.error.HTTPError as e: detail = _redact(e.read().decode()[:800], pw) raise SystemExit(f"--assert: HTTP {e.code} auf {url}\n{detail}") except OSError as e: raise SystemExit(f"--assert: {host} nicht erreichbar ({e})") def _field(obj, field): """RouterOS-REST liefert Sub-Properties teils flach ('channel.width'), teils verschachtelt. Beides bedienen.""" if field in obj: return obj[field] cur = obj for part in field.split("."): if not isinstance(cur, dict) or part not in cur: return None cur = cur[part] return cur ASSERT_RE = re.compile(r"^(?Pcmd:)?(?P[^|?]+)(?:\?(?P[^|]*))?\|" r"(?P[^=]+)=(?P.*)$") def run_assertions(name, specs): print(f"\n -- Wirksamkeitspruefung am Geraet ({len(specs)}) --") failures = [] for spec in specs: m = ASSERT_RE.match(spec) if not m: raise SystemExit( f"--assert: Spec nicht lesbar: {spec!r}\n" f" Erwartet: [cmd:][?query]|=") path, query = m["path"], m["query"] or "" field, expect = m["field"].strip(), m["expect"].strip() res = rest_call(name, path, query, post=bool(m["cmd"])) if isinstance(res, dict): res = [res] if field == "count": actual = str(len(res)) elif not res: actual = "" else: actual = str(_field(res[0], field)) ok = actual == expect print(f" {'OK ' if ok else 'FEHL'} {path}" f"{'?' + query if query else ''} {field}={actual}" f"{'' if ok else f' (erwartet {expect})'}") if not ok: failures.append(f"{path} {field}: ist {actual!r}, erwartet {expect!r}") if failures: raise SystemExit( "\n DEPLOY HAT NICHT GEWIRKT — Plan war 'Completed', das Geraet sagt etwas anderes:\n" + "\n".join(f" - {f}" for f in failures) + "\n Ist-Stand am Geraet pruefen (ein gescheiterter Netmiko-Deploy\n" " hinterlaesst Teil-Zustand, TP22-Lehre).") print(" alle Zusicherungen halten.") def main(): p = argparse.ArgumentParser() p.add_argument("--device", required=True) p.add_argument("--commands-file", required=True) p.add_argument("--change-id", default="") p.add_argument("--change-url", default="") p.add_argument("--deploy", action="store_true", help="nach dem Anlegen freigeben und ausrollen") p.add_argument("--assert", dest="assertions", action="append", metavar="SPEC", help="Wirksamkeitspruefung nach dem Deploy, am Geraet per REST. " "Format: [cmd:][?query]|=. " "Mehrfach angebbar. Bei Funkaenderungen 'monitor' lesen, nie das Profil.") p.add_argument("--dry-run", action="store_true", help="nur zeigen, was gepusht wuerde — legt keinen Plan an") args = p.parse_args() # Kommentar- und Leerzeilen fliegen raus: der Plan-config_set geht 1:1 per # netmiko an die RouterOS-Konsole, dort haben Doku-Zeilen nichts verloren. # Die kommentierte .rsc bleibt das lesbare Artefakt im Repo. raw = pathlib.Path(args.commands_file).read_text() commands = "\n".join(l for l in (x.rstrip() for x in raw.splitlines()) if l.strip() and not l.lstrip().startswith("#")) if not commands: raise SystemExit("commands-file enthaelt keine Kommandozeilen") print(f" Geraet: {args.device}") print(f" Change: {args.change_id or '(keine)'}") print(f" Kommandos ({len(commands.splitlines())} Zeilen):") for line in commands.splitlines(): print(f" | {line}") if args.dry_run: print("\n DRY-RUN — kein Plan angelegt, nichts gepusht.") return device_id = resolve_device(args.device) print(f"\n Device-UUID: {device_id}") known = {r["id"] for r in api(f"/api/plugins/golden-config/config-plan/?device_id={device_id}&limit=200")["results"]} print(" -> Generate Config Plans (plan_type=manual)") run = api(f"/api/extras/jobs/{GEN_JOB}/run/", "POST", {"data": { "device": [device_id], "plan_type": "manual", "commands": commands, "change_control_id": args.change_id, "change_control_url": args.change_url, }}) jr = run.get("job_result", run).get("id") st = wait_job(jr, "generate") job_log(jr) if st != "SUCCESS": raise SystemExit(f"Plan-Erzeugung fehlgeschlagen: {st}") plans = api(f"/api/plugins/golden-config/config-plan/?device_id={device_id}&limit=200")["results"] new = [r for r in plans if r["id"] not in known] if len(new) != 1: raise SystemExit(f"Erwartet genau 1 neuen Plan, gefunden: {len(new)}") plan = new[0] print(f" Plan angelegt: {plan['id']}") # Gegenprobe, dass der Plan wirklich an diesem Geraet haengt (Lehre 2). plan_dev = api(f"/api/plugins/golden-config/config-plan/{plan['id']}/?depth=1")["device"] assert plan_dev["name"] == args.device, f"Plan haengt an {plan_dev['name']}, nicht an {args.device}" print(f" Plan.device verifiziert: {plan_dev['name']}") if not args.deploy: print("\n Plan liegt bereit (nicht freigegeben). Mit --deploy ausrollen.") return print(" -> Plan auf Approved") api(f"/api/plugins/golden-config/config-plan/{plan['id']}/", "PATCH", {"status": status_id("Approved")}) print(" -> Deploy Config Plans") run = api(f"/api/extras/jobs/{DEPLOY_JOB}/run/", "POST", {"data": { "config_plan": [plan["id"]], "fail_job_on_task_failure": True, }}) jr = run.get("job_result", run).get("id") st = wait_job(jr, "deploy") job_log(jr) # Lehre 1: nicht dem Job glauben, den Plan-Status lesen. final = api(f"/api/plugins/golden-config/config-plan/{plan['id']}/?depth=1") plan_status = (final.get("status") or {}).get("name") print(f"\n Job-Status: {st}") print(f" Plan-Status: {plan_status} <- der zaehlt") if plan_status != "Completed": raise SystemExit(f"Deploy nicht sauber durch (Plan: {plan_status})") # Lehre 3: der Plan-Status sagt nichts ueber die Wirkung. if args.assertions: run_assertions(args.device, args.assertions) print("\n Plan Completed UND am Geraet nachgewiesen.") else: print("\n Plan Completed. Abnahme trotzdem erst nach Gegenpruefung am Geraet.") print(" (Tipp: --assert haengt die Gegenpruefung direkt an den Deploy.)") if __name__ == "__main__": main()