feat: allow custom and per-prefix VRF selection
Let users name automatically created tenant VRFs and override the preselected VRF for each new prefix. Keep IP addresses and prefixes in the same selected VRF.
This commit is contained in:
@@ -9,8 +9,8 @@ Windows-Desktopanwendung zum Importieren und Synchronisieren virtueller Maschine
|
|||||||
- Mit Windows DPAPI verschlüsselte Passwörter, API-Token und Token-Secrets
|
- Mit Windows DPAPI verschlüsselte Passwörter, API-Token und Token-Secrets
|
||||||
- Automatische Übernahme des Cluster-Mandanten mit verbindlicher Bestätigung vor dem Sync
|
- Automatische Übernahme des Cluster-Mandanten mit verbindlicher Bestätigung vor dem Sync
|
||||||
- Mandantenzuweisung für IPv4-/IPv6-Adressen und erkannte NetBox-Präfixe
|
- Mandantenzuweisung für IPv4-/IPv6-Adressen und erkannte NetBox-Präfixe
|
||||||
- Bestätigungsdialog mit Subnetzgrößen-Auswahl vor jeder Präfix-Erstellung
|
- Bestätigungsdialog mit Subnetzgrößen- und vorausgefüllter VRF-Auswahl vor jeder Präfix-Erstellung
|
||||||
- Auswahl vorhandener Mandanten-VRFs oder automatische VRF-Erstellung pro Cluster
|
- Auswahl vorhandener Mandanten-VRFs oder automatische VRF-Erstellung pro Cluster mit frei wählbarem Namen
|
||||||
- Optionale Unterstützung selbstsignierter TLS-Zertifikate
|
- Optionale Unterstützung selbstsignierter TLS-Zertifikate
|
||||||
- Synchronisierung von VMs, Hardwaredaten, Interfaces und IP-Adressen
|
- Synchronisierung von VMs, Hardwaredaten, Interfaces und IP-Adressen
|
||||||
- Übernahme primärer IPv4- und IPv6-Adressen
|
- Übernahme primärer IPv4- und IPv6-Adressen
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ class VRFSelectionDialog(tk.Toplevel):
|
|||||||
def __init__(self, parent, tenant_name, cluster_name, vrfs):
|
def __init__(self, parent, tenant_name, cluster_name, vrfs):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.title("VRF auswählen")
|
self.title("VRF auswählen")
|
||||||
self.geometry("540x285")
|
self.geometry("540x355")
|
||||||
self.resizable(False, False)
|
self.resizable(False, False)
|
||||||
self.transient(parent)
|
self.transient(parent)
|
||||||
self.grab_set()
|
self.grab_set()
|
||||||
@@ -199,6 +199,7 @@ class VRFSelectionDialog(tk.Toplevel):
|
|||||||
values.append(self.AUTO_CREATE)
|
values.append(self.AUTO_CREATE)
|
||||||
default = values[0] if vrfs else self.AUTO_CREATE
|
default = values[0] if vrfs else self.AUTO_CREATE
|
||||||
self.selection = tk.StringVar(value=default)
|
self.selection = tk.StringVar(value=default)
|
||||||
|
self.new_vrf_name = tk.StringVar(value=f"{tenant_name} - {cluster_name}")
|
||||||
|
|
||||||
body = ttk.Frame(self, padding=22)
|
body = ttk.Frame(self, padding=22)
|
||||||
body.pack(fill="both", expand=True)
|
body.pack(fill="both", expand=True)
|
||||||
@@ -213,6 +214,8 @@ class VRFSelectionDialog(tk.Toplevel):
|
|||||||
justify="left",
|
justify="left",
|
||||||
).pack(anchor="w", pady=(8, 14))
|
).pack(anchor="w", pady=(8, 14))
|
||||||
ttk.Combobox(body, textvariable=self.selection, values=values, state="readonly").pack(fill="x")
|
ttk.Combobox(body, textvariable=self.selection, values=values, state="readonly").pack(fill="x")
|
||||||
|
ttk.Label(body, text="Name bei automatischer Erstellung").pack(anchor="w", pady=(14, 4))
|
||||||
|
ttk.Entry(body, textvariable=self.new_vrf_name).pack(fill="x")
|
||||||
|
|
||||||
buttons = ttk.Frame(body)
|
buttons = ttk.Frame(body)
|
||||||
buttons.pack(anchor="e", pady=(22, 0))
|
buttons.pack(anchor="e", pady=(22, 0))
|
||||||
@@ -224,23 +227,40 @@ class VRFSelectionDialog(tk.Toplevel):
|
|||||||
def accept(self):
|
def accept(self):
|
||||||
selected = self.selection.get()
|
selected = self.selection.get()
|
||||||
if selected == self.AUTO_CREATE:
|
if selected == self.AUTO_CREATE:
|
||||||
self.result = {"create": True, "id": None}
|
vrf_name = self.new_vrf_name.get().strip()
|
||||||
|
if not vrf_name:
|
||||||
|
messagebox.showwarning("VRF-Name fehlt", "Bitte einen Namen für die neue VRF eingeben.", parent=self)
|
||||||
|
return
|
||||||
|
self.result = {"create": True, "id": None, "name": vrf_name}
|
||||||
else:
|
else:
|
||||||
self.result = {"create": False, "id": self.vrf_map[selected]}
|
self.result = {"create": False, "id": self.vrf_map[selected], "name": None}
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
class PrefixCreationDialog(tk.Toplevel):
|
class PrefixCreationDialog(tk.Toplevel):
|
||||||
"""Ask explicitly before creating a missing IPv4 or IPv6 prefix."""
|
"""Ask explicitly before creating a missing IPv4 or IPv6 prefix."""
|
||||||
|
|
||||||
def __init__(self, parent, address, detected_prefix=True):
|
def __init__(self, parent, address, detected_prefix=True, vrfs=None, default_vrf_id=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.title("Subnetz anlegen?")
|
self.title("Subnetz anlegen?")
|
||||||
self.geometry("520x320")
|
self.geometry("560x390")
|
||||||
self.resizable(False, False)
|
self.resizable(False, False)
|
||||||
self.transient(parent)
|
self.transient(parent)
|
||||||
self.grab_set()
|
self.grab_set()
|
||||||
self.result = None
|
self.result = None
|
||||||
|
self.vrf_map = {}
|
||||||
|
|
||||||
|
vrf_values = []
|
||||||
|
for vrf in vrfs or []:
|
||||||
|
rd = getattr(vrf, "rd", None)
|
||||||
|
display = f"{vrf.name} (RD: {rd})" if rd else vrf.name
|
||||||
|
self.vrf_map[display] = vrf.id
|
||||||
|
vrf_values.append(display)
|
||||||
|
default_vrf = next(
|
||||||
|
(display for display, vrf_id in self.vrf_map.items() if vrf_id == default_vrf_id),
|
||||||
|
vrf_values[0] if vrf_values else "",
|
||||||
|
)
|
||||||
|
self.vrf_selection = tk.StringVar(value=default_vrf)
|
||||||
|
|
||||||
interface = ipaddress.ip_interface(address)
|
interface = ipaddress.ip_interface(address)
|
||||||
self.host_address = interface.ip
|
self.host_address = interface.ip
|
||||||
@@ -277,6 +297,18 @@ class PrefixCreationDialog(tk.Toplevel):
|
|||||||
combo = ttk.Combobox(row, textvariable=self.prefix_length, values=self.choices, state="readonly", width=10)
|
combo = ttk.Combobox(row, textvariable=self.prefix_length, values=self.choices, state="readonly", width=10)
|
||||||
combo.pack(side="left", padx=(12, 0))
|
combo.pack(side="left", padx=(12, 0))
|
||||||
combo.bind("<<ComboboxSelected>>", self.update_preview)
|
combo.bind("<<ComboboxSelected>>", self.update_preview)
|
||||||
|
|
||||||
|
vrf_row = ttk.Frame(body)
|
||||||
|
vrf_row.pack(fill="x", pady=(12, 0))
|
||||||
|
ttk.Label(vrf_row, text="VRF").pack(side="left")
|
||||||
|
vrf_combo = ttk.Combobox(
|
||||||
|
vrf_row,
|
||||||
|
textvariable=self.vrf_selection,
|
||||||
|
values=vrf_values,
|
||||||
|
state="readonly",
|
||||||
|
)
|
||||||
|
vrf_combo.pack(side="left", fill="x", expand=True, padx=(12, 0))
|
||||||
|
vrf_combo.bind("<<ComboboxSelected>>", self.update_preview)
|
||||||
ttk.Label(body, textvariable=self.preview_text, foreground="#334e68", font=("Segoe UI Semibold", 10)).pack(
|
ttk.Label(body, textvariable=self.preview_text, foreground="#334e68", font=("Segoe UI Semibold", 10)).pack(
|
||||||
anchor="w", pady=(14, 0)
|
anchor="w", pady=(14, 0)
|
||||||
)
|
)
|
||||||
@@ -300,10 +332,15 @@ class PrefixCreationDialog(tk.Toplevel):
|
|||||||
return ipaddress.ip_network(f"{self.host_address}/{length}", strict=False)
|
return ipaddress.ip_network(f"{self.host_address}/{length}", strict=False)
|
||||||
|
|
||||||
def update_preview(self, _event=None):
|
def update_preview(self, _event=None):
|
||||||
self.preview_text.set(f"Wird angelegt: {self.selected_network()}")
|
vrf_name = self.vrf_selection.get() or "keine VRF"
|
||||||
|
self.preview_text.set(f"Wird angelegt: {self.selected_network()} · VRF: {vrf_name}")
|
||||||
|
|
||||||
def accept(self):
|
def accept(self):
|
||||||
network = self.selected_network()
|
network = self.selected_network()
|
||||||
|
selected_vrf = self.vrf_selection.get()
|
||||||
|
if selected_vrf not in self.vrf_map:
|
||||||
|
messagebox.showwarning("VRF fehlt", "Bitte eine VRF für das Präfix auswählen.", parent=self)
|
||||||
|
return
|
||||||
if network.version == 4 and str(network.network_address).split(".")[-1] != "0":
|
if network.version == 4 and str(network.network_address).split(".")[-1] != "0":
|
||||||
messagebox.showerror(
|
messagebox.showerror(
|
||||||
"Ungültige Netzadresse",
|
"Ungültige Netzadresse",
|
||||||
@@ -311,7 +348,7 @@ class PrefixCreationDialog(tk.Toplevel):
|
|||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
self.result = str(network)
|
self.result = {"network": str(network), "vrf_id": self.vrf_map[selected_vrf]}
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
@@ -1049,7 +1086,7 @@ class NetBoxVMImporter:
|
|||||||
self.log(f"Vorhandene VRF ausgewählt: {selected.name}")
|
self.log(f"Vorhandene VRF ausgewählt: {selected.name}")
|
||||||
return vrf_id
|
return vrf_id
|
||||||
|
|
||||||
vrf_name = f"{tenant_name} - {cluster_name}"
|
vrf_name = dialog.result["name"]
|
||||||
existing = next(
|
existing = next(
|
||||||
(
|
(
|
||||||
vrf for vrf in self.vrfs
|
vrf for vrf in self.vrfs
|
||||||
@@ -1114,10 +1151,16 @@ class NetBoxVMImporter:
|
|||||||
address = self.ip_with_prefix(ip_value)
|
address = self.ip_with_prefix(ip_value)
|
||||||
if not address:
|
if not address:
|
||||||
continue
|
continue
|
||||||
|
effective_vrf_id = self.sync_prefix_tenant(
|
||||||
|
address,
|
||||||
|
tenant_id,
|
||||||
|
prefix_is_detected="/" in ip_value,
|
||||||
|
default_vrf_id=vrf_id,
|
||||||
|
)
|
||||||
ip_payload = {
|
ip_payload = {
|
||||||
"address": address, "status": "active",
|
"address": address, "status": "active",
|
||||||
"tenant": tenant_id,
|
"tenant": tenant_id,
|
||||||
"vrf": vrf_id,
|
"vrf": effective_vrf_id,
|
||||||
"assigned_object_type": "virtualization.vminterface",
|
"assigned_object_type": "virtualization.vminterface",
|
||||||
"assigned_object_id": netbox_interface.id,
|
"assigned_object_id": netbox_interface.id,
|
||||||
}
|
}
|
||||||
@@ -1125,7 +1168,7 @@ class NetBoxVMImporter:
|
|||||||
host_address = str(ipaddress.ip_interface(address).ip)
|
host_address = str(ipaddress.ip_interface(address).ip)
|
||||||
ip_object = self.nb.ipam.ip_addresses.get(
|
ip_object = self.nb.ipam.ip_addresses.get(
|
||||||
address=host_address,
|
address=host_address,
|
||||||
vrf_id=vrf_id,
|
vrf_id=effective_vrf_id,
|
||||||
)
|
)
|
||||||
if ip_object:
|
if ip_object:
|
||||||
ip_object.update(ip_payload)
|
ip_object.update(ip_payload)
|
||||||
@@ -1133,12 +1176,6 @@ class NetBoxVMImporter:
|
|||||||
ip_object = self.nb.ipam.ip_addresses.create(ip_payload)
|
ip_object = self.nb.ipam.ip_addresses.create(ip_payload)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
raise RuntimeError(f"IP-Adresse {address}: {error}") from error
|
raise RuntimeError(f"IP-Adresse {address}: {error}") from error
|
||||||
self.sync_prefix_tenant(
|
|
||||||
address,
|
|
||||||
tenant_id,
|
|
||||||
prefix_is_detected="/" in ip_value,
|
|
||||||
ip_object=ip_object,
|
|
||||||
)
|
|
||||||
version = ipaddress.ip_interface(address).version
|
version = ipaddress.ip_interface(address).version
|
||||||
if version == 4 and not primary_ipv4:
|
if version == 4 and not primary_ipv4:
|
||||||
primary_ipv4 = ip_object.id
|
primary_ipv4 = ip_object.id
|
||||||
@@ -1153,8 +1190,8 @@ class NetBoxVMImporter:
|
|||||||
if primary:
|
if primary:
|
||||||
netbox_vm.update(primary)
|
netbox_vm.update(primary)
|
||||||
|
|
||||||
def sync_prefix_tenant(self, address, tenant_id, prefix_is_detected, ip_object=None):
|
def sync_prefix_tenant(self, address, tenant_id, prefix_is_detected, default_vrf_id):
|
||||||
"""Assign a tenant and ask explicitly before creating a missing prefix."""
|
"""Assign a tenant, choose a VRF, and ask before creating a missing prefix."""
|
||||||
if not hasattr(self, "synced_prefixes"):
|
if not hasattr(self, "synced_prefixes"):
|
||||||
self.synced_prefixes = set()
|
self.synced_prefixes = set()
|
||||||
if not hasattr(self, "prefix_decisions"):
|
if not hasattr(self, "prefix_decisions"):
|
||||||
@@ -1162,16 +1199,14 @@ class NetBoxVMImporter:
|
|||||||
parsed_interface = ipaddress.ip_interface(address)
|
parsed_interface = ipaddress.ip_interface(address)
|
||||||
host_address = parsed_interface.ip
|
host_address = parsed_interface.ip
|
||||||
network = str(parsed_interface.network)
|
network = str(parsed_interface.network)
|
||||||
ip_vrf = getattr(ip_object, "vrf", None) if ip_object else None
|
vrf_id = default_vrf_id
|
||||||
vrf_id = getattr(ip_vrf, "id", None) if ip_vrf else None
|
|
||||||
|
|
||||||
for cached_network, cached_vrf in self.synced_prefixes:
|
for cached_network, cached_vrf in self.synced_prefixes:
|
||||||
if cached_vrf == vrf_id and host_address in ipaddress.ip_network(cached_network):
|
if host_address in ipaddress.ip_network(cached_network):
|
||||||
return
|
return cached_vrf
|
||||||
|
|
||||||
cache_key = (network, vrf_id)
|
cache_key = (network, vrf_id)
|
||||||
candidates = list(self.nb.ipam.prefixes.filter(prefix=network))
|
candidates = list(self.nb.ipam.prefixes.filter(prefix=network))
|
||||||
if vrf_id is not None:
|
|
||||||
candidates = [
|
candidates = [
|
||||||
prefix for prefix in candidates
|
prefix for prefix in candidates
|
||||||
if getattr(getattr(prefix, "vrf", None), "id", None) == vrf_id
|
if getattr(getattr(prefix, "vrf", None), "id", None) == vrf_id
|
||||||
@@ -1181,36 +1216,49 @@ class NetBoxVMImporter:
|
|||||||
self.log(
|
self.log(
|
||||||
f"Präfix {network} nicht geändert: mehrere passende VRFs gefunden"
|
f"Präfix {network} nicht geändert: mehrere passende VRFs gefunden"
|
||||||
)
|
)
|
||||||
return
|
return vrf_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if candidates:
|
if candidates:
|
||||||
candidates[0].update({"tenant": tenant_id})
|
candidates[0].update({"tenant": tenant_id})
|
||||||
self.log(f"Mandant am Präfix aktualisiert: {network}")
|
self.log(f"Mandant am Präfix aktualisiert: {network}")
|
||||||
else:
|
else:
|
||||||
if cache_key not in self.prefix_decisions:
|
decision = self.prefix_decisions.get(cache_key)
|
||||||
|
needs_decision = cache_key not in self.prefix_decisions
|
||||||
|
if decision is not None and host_address not in ipaddress.ip_network(decision["network"]):
|
||||||
|
needs_decision = True
|
||||||
|
if needs_decision:
|
||||||
|
tenant_vrfs = sorted(
|
||||||
|
(
|
||||||
|
vrf for vrf in self.vrfs
|
||||||
|
if getattr(getattr(vrf, "tenant", None), "id", None) == tenant_id
|
||||||
|
),
|
||||||
|
key=lambda item: item.name.casefold(),
|
||||||
|
)
|
||||||
dialog = PrefixCreationDialog(
|
dialog = PrefixCreationDialog(
|
||||||
self.root,
|
self.root,
|
||||||
address,
|
address,
|
||||||
detected_prefix=prefix_is_detected,
|
detected_prefix=prefix_is_detected,
|
||||||
|
vrfs=tenant_vrfs,
|
||||||
|
default_vrf_id=vrf_id,
|
||||||
)
|
)
|
||||||
self.prefix_decisions[cache_key] = dialog.result
|
self.prefix_decisions[cache_key] = dialog.result
|
||||||
selected_network = self.prefix_decisions[cache_key]
|
decision = self.prefix_decisions[cache_key]
|
||||||
if selected_network is None:
|
if decision is None:
|
||||||
self.log(f"Präfix für {host_address} nicht angelegt (Benutzerauswahl)")
|
self.log(f"Präfix für {host_address} nicht angelegt (Benutzerauswahl)")
|
||||||
return
|
return vrf_id
|
||||||
|
|
||||||
network = selected_network
|
network = decision["network"]
|
||||||
|
vrf_id = decision["vrf_id"]
|
||||||
cache_key = (network, vrf_id)
|
cache_key = (network, vrf_id)
|
||||||
selected_candidates = list(self.nb.ipam.prefixes.filter(prefix=network))
|
selected_candidates = list(self.nb.ipam.prefixes.filter(prefix=network))
|
||||||
if vrf_id is not None:
|
|
||||||
selected_candidates = [
|
selected_candidates = [
|
||||||
prefix for prefix in selected_candidates
|
prefix for prefix in selected_candidates
|
||||||
if getattr(getattr(prefix, "vrf", None), "id", None) == vrf_id
|
if getattr(getattr(prefix, "vrf", None), "id", None) == vrf_id
|
||||||
]
|
]
|
||||||
if len(selected_candidates) > 1:
|
if len(selected_candidates) > 1:
|
||||||
self.log(f"Präfix {network} nicht geändert: mehrere passende VRFs gefunden")
|
self.log(f"Präfix {network} nicht geändert: mehrere passende VRFs gefunden")
|
||||||
return
|
return vrf_id
|
||||||
if selected_candidates:
|
if selected_candidates:
|
||||||
selected_candidates[0].update({"tenant": tenant_id})
|
selected_candidates[0].update({"tenant": tenant_id})
|
||||||
self.log(f"Mandant am Präfix aktualisiert: {network}")
|
self.log(f"Mandant am Präfix aktualisiert: {network}")
|
||||||
@@ -1224,6 +1272,7 @@ class NetBoxVMImporter:
|
|||||||
raise RuntimeError(f"Präfix {network}: {error}") from error
|
raise RuntimeError(f"Präfix {network}: {error}") from error
|
||||||
|
|
||||||
self.synced_prefixes.add(cache_key)
|
self.synced_prefixes.add(cache_key)
|
||||||
|
return vrf_id
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user