Merge branch 'tp5-etappe1-log-lauf'
# Conflicts: # docs/teilprojekte.md
This commit is contained in:
197
nautobot/gc-manual-plan.py
Normal file
197
nautobot/gc-manual-plan.py
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/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
|
||||
`<<REDACTED>>`-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()
|
||||
Reference in New Issue
Block a user