TP45 Schicht A: Nautobot .30/.31/.32/.34 -> .154/.155/.156/.158

Oktett-Klasse .150-.199 (App-Services). Nautobot gibt damit .30/.31 an
AGH frei. .157 ist gitea, deshalb nimmt redis .158.

- Datenmigration ueber neues nautobot/tp45-ip-move.py: Nautobot verbietet
  das Aendern einer IP nach dem Anlegen -> anlegen/primary_ip4 umhaengen/
  Altobjekt loeschen, in dieser Reihenfolge
- Stack-Redeploy (PUT /api/stacks/203), Caddy-Backend, Core-Literal *24
  ueber GC deployt (Plan 51a90e25) mit Wirksamkeitspruefung am Geraet
- caddy/deploy.sh: push_verified() ersetzt den push||push-Fallback.
  `pct push` meldet Exit 0 AUCH wenn es die Quelle nicht lesen kann, der
  Fallback griff deshalb nie und `caddy validate` pruefte die ALTE Datei
  -> gruene Abnahme bei unveraendertem Zustand (Symptom: 502 bei
  gesundem Backend)
- 39 Referenzen in 28 Dateien mitgezogen; lebende Doku nachgezogen
  (CLAUDE.md, ip-plan.md, cf-tunnel-mapping §0-Kopf, TP16.3/TP31-Prosa)
- GC-Compliance 140/140, 0 Drifts

Neue Funde (TP45-Prosa + CLAUDE.md):
- Die Core-Adressliste `nautobot` wird von GC nirgends gerendert -> ein
  Recovery von null erzeugt einen Core, in den Nautobot nicht mehr per
  SSH kommt. Gleiche Klasse wie die fehlenden DHCP-Reservierungen.
- Regel *24 (Literal) und *28 (Liste) sind Dubletten; der Literal steht
  bei 0 Paketen. Zusammenlegung bleibt offen.
- gc-manual-plan.py: /api/extras/job-log-entries/ liefert 404 (Nautobot
  3.x) -> bei gescheitertem Deploy fehlt die Fehlermeldung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 13:14:35 +02:00
parent 8c6cb84dd1
commit b52d6aa49e
36 changed files with 296 additions and 48 deletions

View File

@@ -60,7 +60,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen)
@@ -289,7 +289,7 @@ def job_mgmt_acl():
das ist auf dem Core passiert. Umgekehrt fuhren branch/ap1/ap2 ssh/www/winbox/api/ftp/
telnet komplett OHNE Address-Beschraenkung; winbox war auf allen vier offen.
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.30) aus, also aus
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.154) aus, also aus
10.40.0.0/16 -- die Adresse bleibt in jeder gesetzten ACL enthalten. RouterOS wendet
`address=` ohnehin erst auf NEUE Verbindungen an, die laufende Session ueberlebt.
@@ -360,7 +360,7 @@ def job_tp38_oob():
* branch: ether23 untaggen, VLAN99 um untagged=ether23 erweitern, 8 Input-Regeln
(deny-by-default). Die Regeln werden in der Reihenfolge angelegt, in der das
Template sie rendert — established zuerst, drop zuletzt — damit kein LOCKOUT:
die laufende Nautobot-SSH-Session (10.40.20.30) matcht auf Regel 1 (established),
die laufende Nautobot-SSH-Session (10.40.20.154) matcht auf Regel 1 (established),
bevor Regel 8 (drop) greift.
* core: ether23 untaggen, VLAN99 untagged=ether23, EINE Regel "OOB->core mgmt" mit
place-before vor die Log-Regel (sonst landet sie hinter dem finalen drop und
@@ -368,7 +368,7 @@ def job_tp38_oob():
KEIN LOCKOUT-RISIKO: ether23 ist auf beiden Geraeten frei (running=false); der
VLAN99-Umbau aendert nur `untagged`, nicht `tagged`; der Deploy laeuft von Nautobot
(10.40.20.30), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
(10.40.20.154), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
"""
bridge_set = (
':do { /interface bridge port set [find interface=ether23] pvid=99 '

View File

@@ -61,7 +61,7 @@ import urllib.error
import urllib.parse
import urllib.request
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
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

View File

@@ -38,7 +38,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv

View File

@@ -55,7 +55,7 @@ HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HIER)
AKTEN = os.path.join(REPO, "docs", "transaktionen")
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS"}

View File

@@ -2,7 +2,7 @@
"""Restdaten-Ingest 2026-07-22: cudy1/cudy2, sw1-Ports, crs326-core Zielport."""
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ["NAUTOBOT_TOKEN"]
DRY = "--apply" not in sys.argv
log = []

View File

@@ -38,7 +38,7 @@ ConfigContext und DCIM-Modell nicht auseinanderlaufen.
"""
import json, os, sys, urllib.error, urllib.parse, urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv

View File

@@ -20,7 +20,7 @@ PSKs werden bewusst NICHT modelliert (liegen lokal am Geraet).
"""
import json, os, sys, urllib.request, urllib.error, urllib.parse
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv
log = []

View File

@@ -27,7 +27,7 @@ tatsaechlichen Umzug wandert er zurueck auf Sideboard.
"""
import json, os, sys, urllib.error, urllib.parse, urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv

View File

@@ -6,7 +6,7 @@ Quelle: ap1 live via MikroMCP + docs/teilprojekte.md.
"""
import json, sys, urllib.request, urllib.error
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
import os
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
DRY = "--apply" not in sys.argv

View File

@@ -27,7 +27,7 @@ import pathlib
import sys
import urllib.request
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
DEVICE = "crs326-core"
SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env"

View File

@@ -11,7 +11,7 @@ Idempotent; verwendet GraphQL fuer tagged_vlans (REST liefert null).
"""
import json, os, sys, urllib.request, urllib.error, urllib.parse
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv
log = []

View File

@@ -14,7 +14,7 @@ Verifikation NUR per GraphQL (tagged_vlans liest sich per REST als null).
"""
import json, os, sys, urllib.request, urllib.error, urllib.parse
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv
log = []

View File

@@ -32,7 +32,7 @@ Jinja-Template denselben String emittieren kann (sonst Dauer-Diff in der Complia
"""
import json, os, re, sys, urllib.error, urllib.parse, urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv

View File

@@ -38,7 +38,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv

View File

@@ -29,7 +29,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv

View File

@@ -35,7 +35,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv

View File

@@ -36,7 +36,7 @@ UMGEKEHRT GILT: die `vlanXX`-Descriptions auf dem Core NICHT anfassen, die lande
"""
import json, os, sys, urllib.request, urllib.error, urllib.parse
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv
log = []

View File

@@ -22,7 +22,7 @@ import os
import sys
import urllib.request
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
BRANCH_RULES = [
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',

View File

@@ -68,7 +68,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv

View File

@@ -30,7 +30,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv

View File

@@ -36,7 +36,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv

View File

@@ -38,7 +38,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv
@@ -134,10 +134,10 @@ VMS = {
("docker", "10.40.20.126/24", "Docker-Host nested (LXC 400)"),
],
"nas-bf": [
("nautobot", "10.40.20.30/24", "Nautobot Web (DS923 ep14)"),
("nautobot-worker", "10.40.20.31/24", "Nautobot Golden-Config Worker"),
("nautobot-scheduler", "10.40.20.32/24", "Nautobot Scheduler"),
("nautobot-redis", "10.40.20.34/24", "Nautobot Redis"),
("nautobot", "10.40.20.154/24", "Nautobot Web (DS923 ep14)"),
("nautobot-worker", "10.40.20.155/24", "Nautobot Golden-Config Worker"),
("nautobot-scheduler", "10.40.20.156/24", "Nautobot Scheduler"),
("nautobot-redis", "10.40.20.158/24", "Nautobot Redis"),
("gitea", "10.40.20.157/24", "Gitea (git-SSoT)"),
("vaultwarden", "10.40.20.127/24", "Vaultwarden (DB=mariadb LXC302)"),
("shelly-mcp", "10.40.20.180/24", "Shelly-MCP"),

View File

@@ -29,7 +29,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"

View File

@@ -32,7 +32,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv

View File

@@ -32,7 +32,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv

131
nautobot/tp45-ip-move.py Normal file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
r"""TP45 Schicht A — primaere IP eines Nautobot-Gastes umziehen.
WARUM ES DIESES SKRIPT GIBT
---------------------------
Nautobot verbietet das Aendern einer IP-Adresse nach dem Anlegen
(`{"__all__": ["Host address cannot be changed once created"]}`). Ein Umzug
ist deshalb kein PATCH, sondern ein Dreischritt je Gast:
1. neues ipam.IPAddress am Ziel anlegen (Status/Namespace vom Altobjekt)
2. altes Objekt an die VM-Schnittstelle haengen und primary_ip4 umhaengen
3. altes Objekt loeschen
Von Hand ist das pro Gast drei API-Aufrufe in genau dieser Reihenfolge — bei
~15 Gaesten in Schicht A eine sichere Fehlerquelle. Reihenfolge ist zwingend:
wer das Altobjekt vor dem Umhaengen loescht, reisst die primary_ip4-Referenz.
⚠️ Die IP allein ist NICHT der ganze Umzug. Ein Gast, den der Core ueber eine
Firewall-Adressliste erreicht (z.B. `nautobot`), braucht die neue IP ZUERST in
dieser Liste — sonst steht der Zugriff weg, bevor die Migration durch ist.
python3 nautobot/tp45-ip-move.py # Dry-Run (Default)
python3 nautobot/tp45-ip-move.py --apply
"""
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
BASE = "http://10.40.20.154:8080/api"
APPLY = "--apply" in sys.argv
# (VM-Name, alte IP, neue IP) — Oktett-Klassen nach TP32, s. docs/teilprojekte.md ### TP45
# Charge 1 (2026-09-21, angewendet). Die Altobjekte sind weg — ein erneuter Lauf
# bricht darum sauber ab ("0 Treffer"), er zieht nichts doppelt um.
MOVES = [
("nautobot", "10.40.20.30/24", "10.40.20.154/24"),
("nautobot-worker", "10.40.20.31/24", "10.40.20.155/24"),
("nautobot-scheduler", "10.40.20.32/24", "10.40.20.156/24"),
("nautobot-redis", "10.40.20.34/24", "10.40.20.158/24"),
]
def load_token() -> str:
for line in YML.read_text().splitlines():
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
if m:
return m.group(1)
raise SystemExit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML}")
TOK = load_token()
def api(path, method="GET", payload=None):
req = urllib.request.Request(
BASE + path, method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Authorization": f"Token {TOK}", "Content-Type": "application/json",
"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r) if r.status != 204 else {}
except urllib.error.HTTPError as e:
raise SystemExit(f"{method} {path} -> {e.code}: {e.read().decode()[:300]}")
def only(path, expect=1):
res = api(path)["results"]
if len(res) != expect:
raise SystemExit(f"{path}: {len(res)} Treffer, erwartet {expect}")
return res[0] if expect == 1 else res
def main():
print(f"== TP45 IP-Move ({'APPLY' if APPLY else 'DRY-RUN'}) ==")
for vm_name, old_ip, new_ip in MOVES:
print(f"\n-- {vm_name}: {old_ip} -> {new_ip} --")
old = only(f"/ipam/ip-addresses/?address={old_ip}")
vm = only(f"/virtualization/virtual-machines/?name={vm_name}")
ref = vm.get("primary_ip4")
ref = ref.get("id") if isinstance(ref, dict) else ref
if ref != old["id"]:
raise SystemExit(f" primary_ip4 von {vm_name} zeigt nicht auf {old_ip} "
f"(ist {ref}) — Abbruch, nichts geschrieben")
if api(f"/ipam/ip-addresses/?address={new_ip}")["count"]:
raise SystemExit(f" {new_ip} ist in Nautobot schon belegt — Abbruch")
print(f" Altobjekt={old['id'][:8]} primary_ip4 bestaetigt Ziel frei")
if not APPLY:
print(" [DRY-RUN] wuerde: anlegen -> eth0 verknuepfen -> primary_ip4 "
"umhaengen -> Altobjekt loeschen")
continue
# namespace ist am Altobjekt nicht serialisiert und beim Anlegen Pflicht
ns = old.get("namespace")
ns_id = (ns or {}).get("id") if isinstance(ns, dict) else ns
if not ns_id:
ns_id = only("/ipam/namespaces/?name=Global")["id"]
st = old.get("status")
new = api("/ipam/ip-addresses/", "POST",
{"address": new_ip,
"status": st["id"] if isinstance(st, dict) else st,
"namespace": ns_id, "dns_name": ""})
print(f" + angelegt {new['id'][:8]} @ {new['address']}")
vmif = api(f"/virtualization/interfaces/?virtual_machine={vm['id']}&name=eth0")["results"]
if vmif:
api("/ipam/ip-address-to-interface/", "POST",
{"ip_address": new["id"], "vm_interface": vmif[0]["id"]})
print(f" + verknuepft mit {vm_name}/eth0 ({vmif[0]['id'][:8]})")
else:
print(" ~ keine eth0-Schnittstelle — Verknuepfung uebersprungen")
api(f"/virtualization/virtual-machines/{vm['id']}/", "PATCH", {"primary_ip4": new["id"]})
print(f" + primary_ip4 umgehaengt")
api(f"/ipam/ip-addresses/{old['id']}/", "DELETE")
print(f" - Altobjekt {old_ip} geloescht")
print("\nFertig." if APPLY else "\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
if __name__ == "__main__":
main()