#!/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. """ import argparse import json import os import pathlib import sys import time import urllib.error import urllib.request NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30: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')}") 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("--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})") print("\n Plan Completed. Abnahme trotzdem erst nach Gegenpruefung am Geraet.") if __name__ == "__main__": main()