upload
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# Compatibility Matrix
|
||||||
|
|
||||||
|
| Plugin Release | Minimum NetBox Version | Maximum NetBox Version |
|
||||||
|
|----------------|------------------------|------------------------|
|
||||||
|
| 0.1.0 | 4.4.0 | 4.5.x |
|
||||||
+672
@@ -0,0 +1,672 @@
|
|||||||
|
import ssl
|
||||||
|
import atexit
|
||||||
|
import ipaddress
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, messagebox
|
||||||
|
|
||||||
|
from pyVim.connect import SmartConnect, Disconnect
|
||||||
|
from pyVmomi import vim
|
||||||
|
import pynetbox
|
||||||
|
|
||||||
|
ssl._create_default_https_context = ssl._create_unverified_context
|
||||||
|
|
||||||
|
|
||||||
|
class ESXiNetBoxImporter:
|
||||||
|
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("ESXi -> NetBox VM Synchronizer")
|
||||||
|
self.root.geometry("950x750")
|
||||||
|
|
||||||
|
self.si = None
|
||||||
|
self.nb = None
|
||||||
|
self.vms = []
|
||||||
|
|
||||||
|
self.site_map = {}
|
||||||
|
self.cluster_map = {}
|
||||||
|
self.tenant_map = {}
|
||||||
|
self.platforms = []
|
||||||
|
|
||||||
|
self.create_gui()
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# GUI
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def create_gui(self):
|
||||||
|
|
||||||
|
main = ttk.Frame(self.root, padding=10)
|
||||||
|
main.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ESXi / vCenter
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
ttk.Label(main, text="ESXi / vCenter Host").grid(
|
||||||
|
row=0,
|
||||||
|
column=0,
|
||||||
|
sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.esxi_host = ttk.Entry(main, width=45)
|
||||||
|
self.esxi_host.grid(row=0, column=1, sticky="ew")
|
||||||
|
|
||||||
|
ttk.Label(main, text="Username").grid(
|
||||||
|
row=1,
|
||||||
|
column=0,
|
||||||
|
sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.esxi_user = ttk.Entry(main, width=45)
|
||||||
|
self.esxi_user.grid(row=1, column=1, sticky="ew")
|
||||||
|
|
||||||
|
ttk.Label(main, text="Password").grid(
|
||||||
|
row=2,
|
||||||
|
column=0,
|
||||||
|
sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.esxi_pass = ttk.Entry(main, width=45, show="*")
|
||||||
|
self.esxi_pass.grid(row=2, column=1, sticky="ew")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# NetBox
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
ttk.Label(main, text="NetBox URL").grid(
|
||||||
|
row=3,
|
||||||
|
column=0,
|
||||||
|
sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.nb_url = ttk.Entry(main, width=45)
|
||||||
|
self.nb_url.grid(row=3, column=1, sticky="ew")
|
||||||
|
|
||||||
|
ttk.Label(main, text="NetBox Token").grid(
|
||||||
|
row=4,
|
||||||
|
column=0,
|
||||||
|
sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.nb_token = ttk.Entry(main, width=45, show="*")
|
||||||
|
self.nb_token.grid(row=4, column=1, sticky="ew")
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
main,
|
||||||
|
text="Verbinden & Daten laden",
|
||||||
|
command=self.connect_all
|
||||||
|
).grid(
|
||||||
|
row=5,
|
||||||
|
column=1,
|
||||||
|
sticky="e",
|
||||||
|
pady=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Zielauswahl
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
ttk.Label(main, text="Site").grid(
|
||||||
|
row=6,
|
||||||
|
column=0,
|
||||||
|
sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.site_combo = ttk.Combobox(main, width=42)
|
||||||
|
self.site_combo.grid(row=6, column=1, sticky="ew")
|
||||||
|
|
||||||
|
ttk.Label(main, text="Cluster").grid(
|
||||||
|
row=7,
|
||||||
|
column=0,
|
||||||
|
sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.cluster_combo = ttk.Combobox(main, width=42)
|
||||||
|
self.cluster_combo.grid(row=7, column=1, sticky="ew")
|
||||||
|
|
||||||
|
ttk.Label(main, text="Tenant").grid(
|
||||||
|
row=8,
|
||||||
|
column=0,
|
||||||
|
sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.tenant_combo = ttk.Combobox(main, width=42)
|
||||||
|
self.tenant_combo.grid(row=8, column=1, sticky="ew")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# VM Liste
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
ttk.Label(main, text="Gefundene VMs").grid(
|
||||||
|
row=9,
|
||||||
|
column=0,
|
||||||
|
sticky="w",
|
||||||
|
pady=(15, 5)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.vm_list = tk.Listbox(
|
||||||
|
main,
|
||||||
|
selectmode=tk.MULTIPLE,
|
||||||
|
width=100,
|
||||||
|
height=25
|
||||||
|
)
|
||||||
|
|
||||||
|
self.vm_list.grid(
|
||||||
|
row=10,
|
||||||
|
column=0,
|
||||||
|
columnspan=2,
|
||||||
|
sticky="nsew"
|
||||||
|
)
|
||||||
|
|
||||||
|
scrollbar = ttk.Scrollbar(
|
||||||
|
main,
|
||||||
|
orient="vertical",
|
||||||
|
command=self.vm_list.yview
|
||||||
|
)
|
||||||
|
|
||||||
|
scrollbar.grid(row=10, column=2, sticky="ns")
|
||||||
|
|
||||||
|
self.vm_list.config(yscrollcommand=scrollbar.set)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Buttons
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
btn_frame = ttk.Frame(main)
|
||||||
|
|
||||||
|
btn_frame.grid(
|
||||||
|
row=11,
|
||||||
|
column=0,
|
||||||
|
columnspan=2,
|
||||||
|
pady=10,
|
||||||
|
sticky="e"
|
||||||
|
)
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
btn_frame,
|
||||||
|
text="Alle auswählen",
|
||||||
|
command=self.select_all
|
||||||
|
).pack(side="left", padx=5)
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
btn_frame,
|
||||||
|
text="Import / Sync starten",
|
||||||
|
command=self.import_vms
|
||||||
|
).pack(side="left", padx=5)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Logfeld
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
ttk.Label(main, text="Log").grid(
|
||||||
|
row=12,
|
||||||
|
column=0,
|
||||||
|
sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log_text = tk.Text(main, height=10)
|
||||||
|
|
||||||
|
self.log_text.grid(
|
||||||
|
row=13,
|
||||||
|
column=0,
|
||||||
|
columnspan=2,
|
||||||
|
sticky="nsew"
|
||||||
|
)
|
||||||
|
|
||||||
|
main.columnconfigure(1, weight=1)
|
||||||
|
main.rowconfigure(10, weight=1)
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Logging
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def log(self, text):
|
||||||
|
|
||||||
|
self.log_text.insert(tk.END, f"{text}\n")
|
||||||
|
self.log_text.see(tk.END)
|
||||||
|
|
||||||
|
self.root.update()
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Select All
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def select_all(self):
|
||||||
|
|
||||||
|
self.vm_list.select_set(0, tk.END)
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Verbindungen
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def connect_all(self):
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
self.connect_esxi()
|
||||||
|
self.connect_netbox()
|
||||||
|
|
||||||
|
self.load_netbox_data()
|
||||||
|
self.load_vms()
|
||||||
|
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Erfolg",
|
||||||
|
"Verbindung erfolgreich hergestellt"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
|
||||||
|
messagebox.showerror(
|
||||||
|
"Fehler",
|
||||||
|
str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
def connect_esxi(self):
|
||||||
|
|
||||||
|
self.log("Verbinde zu ESXi/vCenter...")
|
||||||
|
|
||||||
|
context = ssl._create_unverified_context()
|
||||||
|
|
||||||
|
self.si = SmartConnect(
|
||||||
|
host=self.esxi_host.get(),
|
||||||
|
user=self.esxi_user.get(),
|
||||||
|
pwd=self.esxi_pass.get(),
|
||||||
|
sslContext=context
|
||||||
|
)
|
||||||
|
|
||||||
|
atexit.register(Disconnect, self.si)
|
||||||
|
|
||||||
|
self.log("ESXi Verbindung erfolgreich")
|
||||||
|
|
||||||
|
def connect_netbox(self):
|
||||||
|
|
||||||
|
self.log("Verbinde zu NetBox...")
|
||||||
|
|
||||||
|
self.nb = pynetbox.api(
|
||||||
|
self.nb_url.get(),
|
||||||
|
token=self.nb_token.get()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log("NetBox Verbindung erfolgreich")
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# NetBox Daten
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def load_netbox_data(self):
|
||||||
|
|
||||||
|
self.log("Lade NetBox Daten...")
|
||||||
|
|
||||||
|
# Sites
|
||||||
|
sites = list(self.nb.dcim.sites.all())
|
||||||
|
self.site_map = {x.name: x.id for x in sites}
|
||||||
|
self.site_combo["values"] = list(self.site_map.keys())
|
||||||
|
|
||||||
|
# Cluster
|
||||||
|
clusters = list(self.nb.virtualization.clusters.all())
|
||||||
|
self.cluster_map = {x.name: x.id for x in clusters}
|
||||||
|
self.cluster_combo["values"] = list(self.cluster_map.keys())
|
||||||
|
|
||||||
|
# Tenant
|
||||||
|
tenants = list(self.nb.tenancy.tenants.all())
|
||||||
|
self.tenant_map = {x.name: x.id for x in tenants}
|
||||||
|
self.tenant_combo["values"] = list(self.tenant_map.keys())
|
||||||
|
|
||||||
|
# Plattformen
|
||||||
|
self.platforms = list(self.nb.dcim.platforms.all())
|
||||||
|
|
||||||
|
self.log("NetBox Daten geladen")
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# VMware VMs laden
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def load_vms(self):
|
||||||
|
|
||||||
|
self.log("Lade VMs aus VMware...")
|
||||||
|
|
||||||
|
content = self.si.RetrieveContent()
|
||||||
|
|
||||||
|
container = content.rootFolder
|
||||||
|
|
||||||
|
view_type = [vim.VirtualMachine]
|
||||||
|
|
||||||
|
recursive = True
|
||||||
|
|
||||||
|
container_view = content.viewManager.CreateContainerView(
|
||||||
|
container,
|
||||||
|
view_type,
|
||||||
|
recursive
|
||||||
|
)
|
||||||
|
|
||||||
|
self.vms = container_view.view
|
||||||
|
|
||||||
|
self.vm_list.delete(0, tk.END)
|
||||||
|
|
||||||
|
for vm in self.vms:
|
||||||
|
self.vm_list.insert(tk.END, vm.name)
|
||||||
|
|
||||||
|
self.log(f"{len(self.vms)} VMs gefunden")
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Plattform Mapping
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def get_platform_id(self, guest_os):
|
||||||
|
|
||||||
|
if not guest_os:
|
||||||
|
return None
|
||||||
|
|
||||||
|
guest_os = guest_os.lower()
|
||||||
|
|
||||||
|
for platform in self.platforms:
|
||||||
|
|
||||||
|
if platform.name.lower() in guest_os:
|
||||||
|
return platform.id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Prefix automatisch ergänzen
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def get_ip_with_prefix(self, ip):
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
addr = ipaddress.ip_address(ip)
|
||||||
|
|
||||||
|
if addr.version == 4:
|
||||||
|
return f"{ip}/24"
|
||||||
|
|
||||||
|
return f"{ip}/64"
|
||||||
|
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# VM Import / Sync
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def import_vms(self):
|
||||||
|
|
||||||
|
selected = self.vm_list.curselection()
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
|
||||||
|
messagebox.showwarning(
|
||||||
|
"Hinweis",
|
||||||
|
"Keine VMs ausgewählt"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
cluster_name = self.cluster_combo.get()
|
||||||
|
tenant_name = self.tenant_combo.get()
|
||||||
|
|
||||||
|
cluster_id = self.cluster_map.get(cluster_name)
|
||||||
|
tenant_id = self.tenant_map.get(tenant_name)
|
||||||
|
|
||||||
|
if not cluster_id:
|
||||||
|
|
||||||
|
messagebox.showerror(
|
||||||
|
"Fehler",
|
||||||
|
"Bitte Cluster auswählen"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
for idx in selected:
|
||||||
|
|
||||||
|
vm = self.vms[idx]
|
||||||
|
|
||||||
|
self.log(f"Synchronisiere VM: {vm.name}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Hardwaredaten
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
memory = int(vm.config.hardware.memoryMB)
|
||||||
|
|
||||||
|
vcpus = int(vm.config.hardware.numCPU)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Disk in MB (NetBox >= 4.x)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
disk_size = 0
|
||||||
|
|
||||||
|
for dev in vm.config.hardware.device:
|
||||||
|
|
||||||
|
if isinstance(dev, vim.vm.device.VirtualDisk):
|
||||||
|
|
||||||
|
# VMware liefert KB
|
||||||
|
# NetBox erwartet MB
|
||||||
|
size_mb = dev.capacityInKB / 1024
|
||||||
|
|
||||||
|
disk_size += round(size_mb)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Betriebssystem
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
guest_os = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
guest_os = vm.config.guestFullName
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
platform_id = self.get_platform_id(guest_os)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Status
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
power_state = str(vm.runtime.powerState)
|
||||||
|
|
||||||
|
if "poweredOn" in power_state:
|
||||||
|
status = "active"
|
||||||
|
else:
|
||||||
|
status = "offline"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# VM Daten
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
vm_data = {
|
||||||
|
"name": vm.name,
|
||||||
|
"cluster": cluster_id,
|
||||||
|
"status": status,
|
||||||
|
"vcpus": vcpus,
|
||||||
|
"memory": memory,
|
||||||
|
"disk": disk_size,
|
||||||
|
"tenant": tenant_id,
|
||||||
|
"platform": platform_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# VM erstellen / aktualisieren
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
nb_vm = self.nb.virtualization.virtual_machines.get(
|
||||||
|
name=vm.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if nb_vm:
|
||||||
|
|
||||||
|
nb_vm.update(vm_data)
|
||||||
|
|
||||||
|
self.log(f"VM aktualisiert: {vm.name}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
nb_vm = self.nb.virtualization.virtual_machines.create(
|
||||||
|
vm_data
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log(f"VM erstellt: {vm.name}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Interfaces + IPs
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
primary_ipv4 = None
|
||||||
|
primary_ipv6 = None
|
||||||
|
|
||||||
|
if vm.guest and hasattr(vm.guest, "net"):
|
||||||
|
|
||||||
|
for net in vm.guest.net:
|
||||||
|
|
||||||
|
mac = getattr(net, "macAddress", None)
|
||||||
|
|
||||||
|
if not mac:
|
||||||
|
continue
|
||||||
|
|
||||||
|
interface_name = getattr(net, "device", None)
|
||||||
|
|
||||||
|
if not interface_name:
|
||||||
|
interface_name = f"NIC-{mac[-5:]}"
|
||||||
|
|
||||||
|
iface_data = {
|
||||||
|
"virtual_machine": nb_vm.id,
|
||||||
|
"name": interface_name,
|
||||||
|
"mac_address": mac,
|
||||||
|
"enabled": True
|
||||||
|
}
|
||||||
|
|
||||||
|
# Interface zuerst über Namen suchen
|
||||||
|
existing_iface = self.nb.virtualization.interfaces.get(
|
||||||
|
virtual_machine_id=nb_vm.id,
|
||||||
|
name=interface_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_iface:
|
||||||
|
|
||||||
|
# Falls MAC geändert wurde -> aktualisieren
|
||||||
|
existing_iface.update(iface_data)
|
||||||
|
|
||||||
|
vm_iface = existing_iface
|
||||||
|
|
||||||
|
self.log(
|
||||||
|
f"Interface aktualisiert: {interface_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
vm_iface = self.nb.virtualization.interfaces.create(
|
||||||
|
iface_data
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log(
|
||||||
|
f"Interface erstellt: {interface_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log(f"Interface synchronisiert: {mac}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# IPs
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
if hasattr(net, "ipAddress"):
|
||||||
|
|
||||||
|
for ip in net.ipAddress:
|
||||||
|
|
||||||
|
if ip.startswith("127."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ip == "::1":
|
||||||
|
continue
|
||||||
|
|
||||||
|
address = self.get_ip_with_prefix(ip)
|
||||||
|
|
||||||
|
if not address:
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing_ip = self.nb.ipam.ip_addresses.get(
|
||||||
|
address=address
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_data = {
|
||||||
|
"address": address,
|
||||||
|
"status": "active",
|
||||||
|
"assigned_object_type": "virtualization.vminterface",
|
||||||
|
"assigned_object_id": vm_iface.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing_ip:
|
||||||
|
|
||||||
|
existing_ip.update(ip_data)
|
||||||
|
|
||||||
|
ip_obj = existing_ip
|
||||||
|
|
||||||
|
self.log(f"IP aktualisiert: {address}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
ip_obj = self.nb.ipam.ip_addresses.create(
|
||||||
|
ip_data
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log(f"IP erstellt: {address}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Primary IP setzen
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
parsed_ip = ipaddress.ip_address(ip)
|
||||||
|
|
||||||
|
if parsed_ip.version == 4:
|
||||||
|
|
||||||
|
if not primary_ipv4:
|
||||||
|
primary_ipv4 = ip_obj.id
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
if not primary_ipv6:
|
||||||
|
primary_ipv6 = ip_obj.id
|
||||||
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Primäre IPs setzen
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
if primary_ipv4:
|
||||||
|
update_data["primary_ip4"] = primary_ipv4
|
||||||
|
|
||||||
|
if primary_ipv6:
|
||||||
|
update_data["primary_ip6"] = primary_ipv6
|
||||||
|
|
||||||
|
if update_data:
|
||||||
|
|
||||||
|
nb_vm.update(update_data)
|
||||||
|
|
||||||
|
self.log(
|
||||||
|
f"Primary IPs gesetzt für {vm.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Fertig",
|
||||||
|
"VM Import / Synchronisierung abgeschlossen"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log("Synchronisierung abgeschlossen")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Main
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
root = tk.Tk()
|
||||||
|
|
||||||
|
app = ESXiNetBoxImporter(root)
|
||||||
|
|
||||||
|
root.mainloop()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from netbox.plugins import PluginConfig
|
||||||
|
|
||||||
|
|
||||||
|
class VMwareImporterConfig(PluginConfig):
|
||||||
|
name = "netbox_vmware_importer"
|
||||||
|
verbose_name = "VMware Importer"
|
||||||
|
description = "Synchronize VMware vSphere virtual machines into NetBox."
|
||||||
|
version = "0.1.0"
|
||||||
|
author = "Internal NetBox Team"
|
||||||
|
base_url = "vmware-importer"
|
||||||
|
min_version = "4.4.0"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
super().ready()
|
||||||
|
|
||||||
|
# Importing registers the scheduler system job with NetBox.
|
||||||
|
from . import jobs # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
config = VMwareImporterConfig
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from utilities.choices import ChoiceSet
|
||||||
|
|
||||||
|
|
||||||
|
class SyncStatusChoices(ChoiceSet):
|
||||||
|
STATUS_NEVER = "never"
|
||||||
|
STATUS_QUEUED = "queued"
|
||||||
|
STATUS_RUNNING = "running"
|
||||||
|
STATUS_SUCCESS = "success"
|
||||||
|
STATUS_FAILED = "failed"
|
||||||
|
|
||||||
|
CHOICES = [
|
||||||
|
(STATUS_NEVER, _("Never synced"), "gray"),
|
||||||
|
(STATUS_QUEUED, _("Queued"), "blue"),
|
||||||
|
(STATUS_RUNNING, _("Running"), "cyan"),
|
||||||
|
(STATUS_SUCCESS, _("Success"), "green"),
|
||||||
|
(STATUS_FAILED, _("Failed"), "red"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet():
|
||||||
|
key = hashlib.sha256(settings.SECRET_KEY.encode("utf-8")).digest()
|
||||||
|
return Fernet(base64.urlsafe_b64encode(key))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(value):
|
||||||
|
if value in (None, ""):
|
||||||
|
return ""
|
||||||
|
return _fernet().encrypt(value.encode("utf-8")).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(value):
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(value.encode("ascii")).decode("utf-8")
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise ValueError("Stored VMware password cannot be decrypted. Re-enter it on the endpoint.") from exc
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from django.db.models import Q
|
||||||
|
import django_filters
|
||||||
|
from dcim.models import Site
|
||||||
|
from netbox.filtersets import NetBoxModelFilterSet
|
||||||
|
from tenancy.models import Tenant
|
||||||
|
from virtualization.models import Cluster
|
||||||
|
|
||||||
|
from .choices import SyncStatusChoices
|
||||||
|
from .models import VCenterEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
class VCenterEndpointFilterSet(NetBoxModelFilterSet):
|
||||||
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name="tenant",
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
)
|
||||||
|
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name="site",
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
)
|
||||||
|
cluster_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name="cluster",
|
||||||
|
queryset=Cluster.objects.all(),
|
||||||
|
)
|
||||||
|
last_status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=SyncStatusChoices,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VCenterEndpoint
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"slug",
|
||||||
|
"enabled",
|
||||||
|
"host",
|
||||||
|
"tenant_id",
|
||||||
|
"site_id",
|
||||||
|
"cluster_id",
|
||||||
|
"last_status",
|
||||||
|
)
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value)
|
||||||
|
| Q(slug__icontains=value)
|
||||||
|
| Q(host__icontains=value)
|
||||||
|
| Q(username__icontains=value)
|
||||||
|
| Q(comments__icontains=value)
|
||||||
|
)
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from dcim.models import Site
|
||||||
|
from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm
|
||||||
|
from tenancy.models import Tenant
|
||||||
|
from utilities.forms.fields import (
|
||||||
|
DynamicModelChoiceField,
|
||||||
|
DynamicModelMultipleChoiceField,
|
||||||
|
MultipleChoiceField,
|
||||||
|
SlugField,
|
||||||
|
)
|
||||||
|
from utilities.forms.rendering import FieldSet
|
||||||
|
from virtualization.models import Cluster
|
||||||
|
|
||||||
|
from .choices import SyncStatusChoices
|
||||||
|
from .models import VCenterEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
class VCenterEndpointForm(NetBoxModelForm):
|
||||||
|
slug = SlugField()
|
||||||
|
tenant = DynamicModelChoiceField(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
site = DynamicModelChoiceField(
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
cluster = DynamicModelChoiceField(
|
||||||
|
queryset=Cluster.objects.all(),
|
||||||
|
)
|
||||||
|
password = forms.CharField(
|
||||||
|
label=_("Password"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.PasswordInput(render_value=False),
|
||||||
|
help_text=_("Leave blank to keep the currently stored password."),
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet("name", "slug", "enabled", "comments", name=_("Endpoint")),
|
||||||
|
FieldSet("host", "port", "username", "password", "validate_ssl", name=_("VMware connection")),
|
||||||
|
FieldSet("tenant", "site", "cluster", name=_("NetBox target")),
|
||||||
|
FieldSet(
|
||||||
|
"include_name_regex",
|
||||||
|
"exclude_name_regex",
|
||||||
|
"sync_powered_off",
|
||||||
|
"sync_interfaces",
|
||||||
|
"sync_ip_addresses",
|
||||||
|
"sync_primary_ips",
|
||||||
|
"update_existing",
|
||||||
|
name=_("Sync behavior"),
|
||||||
|
),
|
||||||
|
FieldSet("default_ipv4_prefix_length", "default_ipv6_prefix_length", "sync_interval_minutes", name=_("Automation")),
|
||||||
|
FieldSet("tags", name=_("Tags")),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VCenterEndpoint
|
||||||
|
fields = (
|
||||||
|
"name",
|
||||||
|
"slug",
|
||||||
|
"enabled",
|
||||||
|
"host",
|
||||||
|
"port",
|
||||||
|
"username",
|
||||||
|
"password",
|
||||||
|
"validate_ssl",
|
||||||
|
"tenant",
|
||||||
|
"site",
|
||||||
|
"cluster",
|
||||||
|
"include_name_regex",
|
||||||
|
"exclude_name_regex",
|
||||||
|
"sync_powered_off",
|
||||||
|
"sync_interfaces",
|
||||||
|
"sync_ip_addresses",
|
||||||
|
"sync_primary_ips",
|
||||||
|
"update_existing",
|
||||||
|
"default_ipv4_prefix_length",
|
||||||
|
"default_ipv6_prefix_length",
|
||||||
|
"sync_interval_minutes",
|
||||||
|
"comments",
|
||||||
|
"tags",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
password = cleaned_data.get("password")
|
||||||
|
|
||||||
|
if not self.instance.pk and not password:
|
||||||
|
raise ValidationError({"password": _("A password is required for new endpoints.")})
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
obj = super().save(commit=False)
|
||||||
|
password = self.cleaned_data.get("password")
|
||||||
|
|
||||||
|
if password:
|
||||||
|
obj.set_password(password)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
obj.save()
|
||||||
|
self.save_m2m()
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class VCenterEndpointFilterForm(NetBoxModelFilterSetForm):
|
||||||
|
model = VCenterEndpoint
|
||||||
|
|
||||||
|
enabled = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_("Enabled"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_("Tenant"),
|
||||||
|
)
|
||||||
|
site_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_("Site"),
|
||||||
|
)
|
||||||
|
cluster_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Cluster.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_("Cluster"),
|
||||||
|
)
|
||||||
|
last_status = MultipleChoiceField(
|
||||||
|
choices=SyncStatusChoices,
|
||||||
|
required=False,
|
||||||
|
label=_("Last status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet("q", "enabled", "tenant_id", "site_id", "cluster_id", "last_status", name=_("Endpoint")),
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from core.exceptions import JobFailed
|
||||||
|
from django.utils import timezone
|
||||||
|
from netbox.jobs import JobRunner, system_job
|
||||||
|
|
||||||
|
from .choices import SyncStatusChoices
|
||||||
|
from .models import VCenterEndpoint
|
||||||
|
from .sync import VMwareImporter
|
||||||
|
|
||||||
|
|
||||||
|
class SyncVCenterEndpointJob(JobRunner):
|
||||||
|
class Meta:
|
||||||
|
name = "VMware VM synchronization"
|
||||||
|
|
||||||
|
def run(self, *args, **kwargs):
|
||||||
|
endpoint = self.job.object
|
||||||
|
if endpoint is None:
|
||||||
|
endpoint_pk = kwargs.get("endpoint_pk")
|
||||||
|
endpoint = VCenterEndpoint.objects.get(pk=endpoint_pk)
|
||||||
|
|
||||||
|
if not endpoint.enabled:
|
||||||
|
endpoint.mark_failure("Endpoint is disabled.")
|
||||||
|
raise JobFailed("Endpoint is disabled.")
|
||||||
|
|
||||||
|
self.logger.info("Starting VMware sync for %s (%s)", endpoint.name, endpoint.host)
|
||||||
|
endpoint.mark_running()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = VMwareImporter(endpoint, self.logger).sync()
|
||||||
|
except Exception as exc:
|
||||||
|
endpoint.mark_failure(exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if result.errors:
|
||||||
|
endpoint.last_sync_at = timezone.now()
|
||||||
|
endpoint.last_status = SyncStatusChoices.STATUS_FAILED
|
||||||
|
endpoint.last_vm_count = result.seen
|
||||||
|
endpoint.last_created_count = result.created
|
||||||
|
endpoint.last_updated_count = result.updated
|
||||||
|
endpoint.last_error_count = result.errors
|
||||||
|
endpoint.last_status = SyncStatusChoices.STATUS_FAILED
|
||||||
|
endpoint.last_message = f"Finished with {result.errors} VM error(s). Check the job log."
|
||||||
|
endpoint.set_next_sync(endpoint.last_sync_at)
|
||||||
|
endpoint.save(
|
||||||
|
update_fields=(
|
||||||
|
"last_sync_at",
|
||||||
|
"last_status",
|
||||||
|
"last_vm_count",
|
||||||
|
"last_created_count",
|
||||||
|
"last_updated_count",
|
||||||
|
"last_error_count",
|
||||||
|
"last_message",
|
||||||
|
"next_sync_at",
|
||||||
|
"last_updated",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise JobFailed(endpoint.last_message)
|
||||||
|
|
||||||
|
endpoint.mark_success(result)
|
||||||
|
self.logger.info(
|
||||||
|
"VMware sync finished for %s: %s VM(s), %s created, %s updated, %s skipped.",
|
||||||
|
endpoint.name,
|
||||||
|
result.synced,
|
||||||
|
result.created,
|
||||||
|
result.updated,
|
||||||
|
result.skipped,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@system_job(interval=5)
|
||||||
|
class ScheduleDueVCenterSyncsJob(JobRunner):
|
||||||
|
class Meta:
|
||||||
|
name = "Schedule due VMware VM synchronizations"
|
||||||
|
|
||||||
|
def run(self, *args, **kwargs):
|
||||||
|
now = timezone.now()
|
||||||
|
due_endpoints = VCenterEndpoint.objects.filter(
|
||||||
|
enabled=True,
|
||||||
|
sync_interval_minutes__isnull=False,
|
||||||
|
).filter(next_sync_at__lte=now)
|
||||||
|
|
||||||
|
queued = 0
|
||||||
|
for endpoint in due_endpoints:
|
||||||
|
SyncVCenterEndpointJob.enqueue_once(instance=endpoint)
|
||||||
|
endpoint.last_status = SyncStatusChoices.STATUS_QUEUED
|
||||||
|
endpoint.last_message = "Automatic sync job queued."
|
||||||
|
endpoint.set_next_sync(now)
|
||||||
|
endpoint.save(update_fields=("last_status", "last_message", "next_sync_at", "last_updated"))
|
||||||
|
queued += 1
|
||||||
|
|
||||||
|
self.logger.info("Queued %s VMware sync job(s).", queued)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("dcim", "0001_initial"),
|
||||||
|
("tenancy", "0001_initial"),
|
||||||
|
("virtualization", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="VCenterEndpoint",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
("last_updated", models.DateTimeField(auto_now=True, null=True)),
|
||||||
|
("custom_field_data", models.JSONField(blank=True, default=dict)),
|
||||||
|
("name", models.CharField(max_length=100, unique=True)),
|
||||||
|
("slug", models.SlugField(max_length=100, unique=True)),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
("host", models.CharField(help_text="vCenter or ESXi hostname or IP address", max_length=255)),
|
||||||
|
(
|
||||||
|
"port",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=443,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(65535),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("username", models.CharField(max_length=255)),
|
||||||
|
("password_ciphertext", models.TextField(blank=True, editable=False)),
|
||||||
|
("validate_ssl", models.BooleanField(default=False, help_text="Validate the VMware endpoint certificate.")),
|
||||||
|
(
|
||||||
|
"include_name_regex",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Only synchronize matching VM names. Leave empty to include all VMs.",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("exclude_name_regex", models.CharField(blank=True, help_text="Skip matching VM names.", max_length=255)),
|
||||||
|
("sync_powered_off", models.BooleanField(default=True, verbose_name="Sync powered-off VMs")),
|
||||||
|
("sync_interfaces", models.BooleanField(default=True)),
|
||||||
|
("sync_ip_addresses", models.BooleanField(default=True)),
|
||||||
|
("sync_primary_ips", models.BooleanField(default=True)),
|
||||||
|
("update_existing", models.BooleanField(default=True, help_text="Update existing VMs matched by name and cluster.")),
|
||||||
|
(
|
||||||
|
"default_ipv4_prefix_length",
|
||||||
|
models.PositiveSmallIntegerField(
|
||||||
|
default=24,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"default_ipv6_prefix_length",
|
||||||
|
models.PositiveSmallIntegerField(
|
||||||
|
default=64,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(128),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sync_interval_minutes",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Optional automatic sync interval in minutes. Leave empty for manual sync only.",
|
||||||
|
null=True,
|
||||||
|
validators=[django.core.validators.MinValueValidator(5)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("next_sync_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||||
|
("last_sync_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||||
|
("last_success_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||||
|
(
|
||||||
|
"last_status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("never", "Never synced"),
|
||||||
|
("queued", "Queued"),
|
||||||
|
("running", "Running"),
|
||||||
|
("success", "Success"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
],
|
||||||
|
default="never",
|
||||||
|
editable=False,
|
||||||
|
max_length=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("last_message", models.CharField(blank=True, editable=False, max_length=500)),
|
||||||
|
("last_vm_count", models.PositiveIntegerField(default=0, editable=False)),
|
||||||
|
("last_created_count", models.PositiveIntegerField(default=0, editable=False)),
|
||||||
|
("last_updated_count", models.PositiveIntegerField(default=0, editable=False)),
|
||||||
|
("last_error_count", models.PositiveIntegerField(default=0, editable=False)),
|
||||||
|
("comments", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"cluster",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="NetBox cluster into which VMware VMs will be synchronized.",
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="vmware_import_endpoints",
|
||||||
|
to="virtualization.cluster",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"site",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="vmware_import_endpoints",
|
||||||
|
to="dcim.site",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tenant",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="vmware_import_endpoints",
|
||||||
|
to="tenancy.tenant",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "vCenter endpoint",
|
||||||
|
"verbose_name_plural": "vCenter endpoints",
|
||||||
|
"ordering": ("name",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from netbox.models import NetBoxModel
|
||||||
|
from netbox.models.features import JobsMixin
|
||||||
|
|
||||||
|
from .choices import SyncStatusChoices
|
||||||
|
from .crypto import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
|
||||||
|
class VCenterEndpoint(JobsMixin, NetBoxModel):
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
slug = models.SlugField(
|
||||||
|
max_length=100,
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
enabled = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
host = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
help_text=_("vCenter or ESXi hostname or IP address"),
|
||||||
|
)
|
||||||
|
port = models.PositiveIntegerField(
|
||||||
|
default=443,
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(65535)],
|
||||||
|
)
|
||||||
|
username = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
)
|
||||||
|
password_ciphertext = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
validate_ssl = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=_("Validate the VMware endpoint certificate."),
|
||||||
|
)
|
||||||
|
tenant = models.ForeignKey(
|
||||||
|
to="tenancy.Tenant",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="vmware_import_endpoints",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
|
site = models.ForeignKey(
|
||||||
|
to="dcim.Site",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="vmware_import_endpoints",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
|
cluster = models.ForeignKey(
|
||||||
|
to="virtualization.Cluster",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="vmware_import_endpoints",
|
||||||
|
help_text=_("NetBox cluster into which VMware VMs will be synchronized."),
|
||||||
|
)
|
||||||
|
include_name_regex = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Only synchronize matching VM names. Leave empty to include all VMs."),
|
||||||
|
)
|
||||||
|
exclude_name_regex = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Skip matching VM names."),
|
||||||
|
)
|
||||||
|
sync_powered_off = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name=_("Sync powered-off VMs"),
|
||||||
|
)
|
||||||
|
sync_interfaces = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
sync_ip_addresses = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
sync_primary_ips = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
update_existing = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text=_("Update existing VMs matched by name and cluster."),
|
||||||
|
)
|
||||||
|
default_ipv4_prefix_length = models.PositiveSmallIntegerField(
|
||||||
|
default=24,
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(32)],
|
||||||
|
)
|
||||||
|
default_ipv6_prefix_length = models.PositiveSmallIntegerField(
|
||||||
|
default=64,
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(128)],
|
||||||
|
)
|
||||||
|
sync_interval_minutes = models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[MinValueValidator(5)],
|
||||||
|
help_text=_("Optional automatic sync interval in minutes. Leave empty for manual sync only."),
|
||||||
|
)
|
||||||
|
next_sync_at = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
last_sync_at = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
last_success_at = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
last_status = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=SyncStatusChoices,
|
||||||
|
default=SyncStatusChoices.STATUS_NEVER,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
last_message = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
last_vm_count = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
last_created_count = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
last_updated_count = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
last_error_count = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
comments = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
clone_fields = (
|
||||||
|
"enabled",
|
||||||
|
"host",
|
||||||
|
"port",
|
||||||
|
"username",
|
||||||
|
"validate_ssl",
|
||||||
|
"tenant",
|
||||||
|
"site",
|
||||||
|
"cluster",
|
||||||
|
"include_name_regex",
|
||||||
|
"exclude_name_regex",
|
||||||
|
"sync_powered_off",
|
||||||
|
"sync_interfaces",
|
||||||
|
"sync_ip_addresses",
|
||||||
|
"sync_primary_ips",
|
||||||
|
"update_existing",
|
||||||
|
"default_ipv4_prefix_length",
|
||||||
|
"default_ipv6_prefix_length",
|
||||||
|
"sync_interval_minutes",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("name",)
|
||||||
|
verbose_name = _("vCenter endpoint")
|
||||||
|
verbose_name_plural = _("vCenter endpoints")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("plugins:netbox_vmware_importer:vcenterendpoint", args=[self.pk])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def password(self):
|
||||||
|
return decrypt_secret(self.password_ciphertext)
|
||||||
|
|
||||||
|
def set_password(self, value):
|
||||||
|
self.password_ciphertext = encrypt_secret(value)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
for field_name in ("include_name_regex", "exclude_name_regex"):
|
||||||
|
pattern = getattr(self, field_name)
|
||||||
|
if not pattern:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
re.compile(pattern)
|
||||||
|
except re.error as exc:
|
||||||
|
raise ValidationError({field_name: _("Invalid regular expression: %(error)s") % {"error": exc}})
|
||||||
|
|
||||||
|
if self.site_id and self.cluster_id and getattr(self.cluster, "site_id", None):
|
||||||
|
if self.cluster.site_id != self.site_id:
|
||||||
|
raise ValidationError({"cluster": _("Selected cluster belongs to a different site.")})
|
||||||
|
|
||||||
|
def mark_queued(self):
|
||||||
|
self.last_status = SyncStatusChoices.STATUS_QUEUED
|
||||||
|
self.last_message = _("Sync job queued.")
|
||||||
|
self.save(update_fields=("last_status", "last_message", "last_updated"))
|
||||||
|
|
||||||
|
def mark_running(self):
|
||||||
|
self.last_sync_at = timezone.now()
|
||||||
|
self.last_status = SyncStatusChoices.STATUS_RUNNING
|
||||||
|
self.last_message = _("Sync job running.")
|
||||||
|
self.save(update_fields=("last_sync_at", "last_status", "last_message", "last_updated"))
|
||||||
|
|
||||||
|
def mark_success(self, result):
|
||||||
|
now = timezone.now()
|
||||||
|
self.last_sync_at = now
|
||||||
|
self.last_success_at = now
|
||||||
|
self.last_status = SyncStatusChoices.STATUS_SUCCESS
|
||||||
|
self.last_vm_count = result.seen
|
||||||
|
self.last_created_count = result.created
|
||||||
|
self.last_updated_count = result.updated
|
||||||
|
self.last_error_count = result.errors
|
||||||
|
self.last_message = _(
|
||||||
|
"Synchronized %(synced)s VM(s), skipped %(skipped)s VM(s)."
|
||||||
|
) % {"synced": result.synced, "skipped": result.skipped}
|
||||||
|
self.set_next_sync(now)
|
||||||
|
self.save(
|
||||||
|
update_fields=(
|
||||||
|
"last_sync_at",
|
||||||
|
"last_success_at",
|
||||||
|
"last_status",
|
||||||
|
"last_vm_count",
|
||||||
|
"last_created_count",
|
||||||
|
"last_updated_count",
|
||||||
|
"last_error_count",
|
||||||
|
"last_message",
|
||||||
|
"next_sync_at",
|
||||||
|
"last_updated",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_failure(self, message):
|
||||||
|
now = timezone.now()
|
||||||
|
self.last_sync_at = now
|
||||||
|
self.last_status = SyncStatusChoices.STATUS_FAILED
|
||||||
|
self.last_message = str(message)[:500]
|
||||||
|
self.set_next_sync(now)
|
||||||
|
self.save(update_fields=("last_sync_at", "last_status", "last_message", "next_sync_at", "last_updated"))
|
||||||
|
|
||||||
|
def set_next_sync(self, now=None):
|
||||||
|
if self.enabled and self.sync_interval_minutes:
|
||||||
|
self.next_sync_at = (now or timezone.now()) + timedelta(minutes=self.sync_interval_minutes)
|
||||||
|
else:
|
||||||
|
self.next_sync_at = None
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.enabled or not self.sync_interval_minutes:
|
||||||
|
self.next_sync_at = None
|
||||||
|
elif not self.next_sync_at:
|
||||||
|
self.set_next_sync()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from netbox.choices import ButtonColorChoices
|
||||||
|
from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
|
||||||
|
|
||||||
|
|
||||||
|
endpoint_item = PluginMenuItem(
|
||||||
|
link="plugins:netbox_vmware_importer:vcenterendpoint_list",
|
||||||
|
link_text="vCenter Endpoints",
|
||||||
|
permissions=["netbox_vmware_importer.view_vcenterendpoint"],
|
||||||
|
buttons=(
|
||||||
|
PluginMenuButton(
|
||||||
|
"plugins:netbox_vmware_importer:vcenterendpoint_add",
|
||||||
|
"Add",
|
||||||
|
"mdi mdi-plus-thick",
|
||||||
|
ButtonColorChoices.GREEN,
|
||||||
|
permissions=["netbox_vmware_importer.add_vcenterendpoint"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
menu = PluginMenu(
|
||||||
|
label="VMware Import",
|
||||||
|
groups=(
|
||||||
|
("VMware", (endpoint_item,)),
|
||||||
|
),
|
||||||
|
icon_class="mdi mdi-cloud-sync",
|
||||||
|
)
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db import transaction
|
||||||
|
from dcim.models import Platform
|
||||||
|
from ipam.models import IPAddress
|
||||||
|
from virtualization.models import VMInterface, VirtualMachine
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyVim.connect import Disconnect, SmartConnect
|
||||||
|
from pyVmomi import vim
|
||||||
|
except ImportError: # pragma: no cover - handled at runtime inside NetBox
|
||||||
|
Disconnect = None
|
||||||
|
SmartConnect = None
|
||||||
|
vim = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InterfaceData:
|
||||||
|
name: str
|
||||||
|
mac_address: str
|
||||||
|
ip_addresses: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VMData:
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
vcpus: int
|
||||||
|
memory_mb: int
|
||||||
|
disk_mb: int
|
||||||
|
guest_os: str = ""
|
||||||
|
interfaces: list[InterfaceData] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SyncResult:
|
||||||
|
seen: int = 0
|
||||||
|
synced: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
created: int = 0
|
||||||
|
updated: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
interfaces_created: int = 0
|
||||||
|
interfaces_updated: int = 0
|
||||||
|
ip_addresses_created: int = 0
|
||||||
|
ip_addresses_updated: int = 0
|
||||||
|
ip_conflicts: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class VMwareConnectionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VMwareClient:
|
||||||
|
def __init__(self, endpoint):
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.service_instance = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if SmartConnect is None:
|
||||||
|
raise VMwareConnectionError("pyVmomi is not installed in the NetBox Python environment.")
|
||||||
|
|
||||||
|
ssl_context = None
|
||||||
|
if not self.endpoint.validate_ssl:
|
||||||
|
ssl_context = ssl._create_unverified_context()
|
||||||
|
|
||||||
|
self.service_instance = SmartConnect(
|
||||||
|
host=self.endpoint.host,
|
||||||
|
port=self.endpoint.port,
|
||||||
|
user=self.endpoint.username,
|
||||||
|
pwd=self.endpoint.password,
|
||||||
|
sslContext=ssl_context,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
if self.service_instance is not None and Disconnect is not None:
|
||||||
|
Disconnect(self.service_instance)
|
||||||
|
|
||||||
|
def iter_virtual_machines(self):
|
||||||
|
content = self.service_instance.RetrieveContent()
|
||||||
|
container_view = content.viewManager.CreateContainerView(
|
||||||
|
content.rootFolder,
|
||||||
|
[vim.VirtualMachine],
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for vm_obj in container_view.view:
|
||||||
|
yield self._build_vm_data(vm_obj)
|
||||||
|
finally:
|
||||||
|
container_view.Destroy()
|
||||||
|
|
||||||
|
def _build_vm_data(self, vm_obj):
|
||||||
|
config = getattr(vm_obj, "config", None)
|
||||||
|
hardware = getattr(config, "hardware", None)
|
||||||
|
|
||||||
|
memory_mb = int(getattr(hardware, "memoryMB", 0) or 0)
|
||||||
|
vcpus = int(getattr(hardware, "numCPU", 0) or 0)
|
||||||
|
disk_mb = self._get_disk_size_mb(hardware)
|
||||||
|
guest_os = getattr(config, "guestFullName", "") or ""
|
||||||
|
power_state = str(getattr(getattr(vm_obj, "runtime", None), "powerState", ""))
|
||||||
|
status = "active" if "poweredOn" in power_state else "offline"
|
||||||
|
|
||||||
|
return VMData(
|
||||||
|
name=vm_obj.name,
|
||||||
|
status=status,
|
||||||
|
vcpus=vcpus,
|
||||||
|
memory_mb=memory_mb,
|
||||||
|
disk_mb=disk_mb,
|
||||||
|
guest_os=guest_os,
|
||||||
|
interfaces=self._get_interfaces(vm_obj),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_disk_size_mb(hardware):
|
||||||
|
disk_mb = 0
|
||||||
|
for device in getattr(hardware, "device", []) or []:
|
||||||
|
if isinstance(device, vim.vm.device.VirtualDisk):
|
||||||
|
disk_mb += round((device.capacityInKB or 0) / 1024)
|
||||||
|
return disk_mb
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_interfaces(vm_obj):
|
||||||
|
interfaces = []
|
||||||
|
guest = getattr(vm_obj, "guest", None)
|
||||||
|
|
||||||
|
for network in getattr(guest, "net", []) or []:
|
||||||
|
mac_address = getattr(network, "macAddress", None)
|
||||||
|
if not mac_address:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = getattr(network, "device", None) or f"NIC-{mac_address[-5:].replace(':', '')}"
|
||||||
|
interfaces.append(
|
||||||
|
InterfaceData(
|
||||||
|
name=name,
|
||||||
|
mac_address=mac_address,
|
||||||
|
ip_addresses=list(getattr(network, "ipAddress", []) or []),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
|
||||||
|
class VMwareImporter:
|
||||||
|
def __init__(self, endpoint, logger):
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.logger = logger
|
||||||
|
self.include_pattern = re.compile(endpoint.include_name_regex) if endpoint.include_name_regex else None
|
||||||
|
self.exclude_pattern = re.compile(endpoint.exclude_name_regex) if endpoint.exclude_name_regex else None
|
||||||
|
self.interface_content_type = None
|
||||||
|
|
||||||
|
def sync(self):
|
||||||
|
result = SyncResult()
|
||||||
|
platforms = list(Platform.objects.all())
|
||||||
|
self.interface_content_type = ContentType.objects.get_for_model(VMInterface)
|
||||||
|
|
||||||
|
with VMwareClient(self.endpoint) as client:
|
||||||
|
for vm_data in client.iter_virtual_machines():
|
||||||
|
result.seen += 1
|
||||||
|
|
||||||
|
if not self._should_sync_vm(vm_data):
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
self._sync_vm(vm_data, platforms, result)
|
||||||
|
result.synced += 1
|
||||||
|
except Exception as exc: # pragma: no cover - needs NetBox integration test
|
||||||
|
result.errors += 1
|
||||||
|
self.logger.error("Failed to synchronize VM %s: %s", vm_data.name, exc)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _should_sync_vm(self, vm_data):
|
||||||
|
if self.include_pattern and not self.include_pattern.search(vm_data.name):
|
||||||
|
return False
|
||||||
|
if self.exclude_pattern and self.exclude_pattern.search(vm_data.name):
|
||||||
|
return False
|
||||||
|
if vm_data.status == "offline" and not self.endpoint.sync_powered_off:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _sync_vm(self, vm_data, platforms, result):
|
||||||
|
nb_vm = VirtualMachine.objects.filter(
|
||||||
|
name=vm_data.name,
|
||||||
|
cluster=self.endpoint.cluster,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
created = nb_vm is None
|
||||||
|
if created:
|
||||||
|
nb_vm = VirtualMachine(
|
||||||
|
name=vm_data.name,
|
||||||
|
cluster=self.endpoint.cluster,
|
||||||
|
)
|
||||||
|
elif not self.endpoint.update_existing:
|
||||||
|
result.skipped += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
nb_vm.status = vm_data.status
|
||||||
|
nb_vm.vcpus = vm_data.vcpus
|
||||||
|
nb_vm.memory = vm_data.memory_mb
|
||||||
|
nb_vm.disk = vm_data.disk_mb
|
||||||
|
nb_vm.tenant = self.endpoint.tenant
|
||||||
|
nb_vm.platform = self._match_platform(vm_data.guest_os, platforms)
|
||||||
|
|
||||||
|
nb_vm.full_clean()
|
||||||
|
nb_vm.save()
|
||||||
|
|
||||||
|
if created:
|
||||||
|
result.created += 1
|
||||||
|
self.logger.info("Created VM %s", vm_data.name)
|
||||||
|
else:
|
||||||
|
result.updated += 1
|
||||||
|
self.logger.info("Updated VM %s", vm_data.name)
|
||||||
|
|
||||||
|
if self.endpoint.sync_interfaces:
|
||||||
|
primary_ipv4, primary_ipv6 = self._sync_interfaces(nb_vm, vm_data, result)
|
||||||
|
self._set_primary_ips(nb_vm, primary_ipv4, primary_ipv6)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match_platform(guest_os, platforms):
|
||||||
|
if not guest_os:
|
||||||
|
return None
|
||||||
|
|
||||||
|
guest_os_lower = guest_os.lower()
|
||||||
|
for platform in platforms:
|
||||||
|
if platform.name.lower() in guest_os_lower:
|
||||||
|
return platform
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _sync_interfaces(self, nb_vm, vm_data, result):
|
||||||
|
primary_ipv4 = None
|
||||||
|
primary_ipv6 = None
|
||||||
|
|
||||||
|
for interface_data in vm_data.interfaces:
|
||||||
|
vm_interface = VMInterface.objects.filter(
|
||||||
|
virtual_machine=nb_vm,
|
||||||
|
name=interface_data.name,
|
||||||
|
).first()
|
||||||
|
created = vm_interface is None
|
||||||
|
|
||||||
|
if created:
|
||||||
|
vm_interface = VMInterface(
|
||||||
|
virtual_machine=nb_vm,
|
||||||
|
name=interface_data.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
vm_interface.mac_address = interface_data.mac_address
|
||||||
|
vm_interface.enabled = True
|
||||||
|
vm_interface.full_clean()
|
||||||
|
vm_interface.save()
|
||||||
|
|
||||||
|
if created:
|
||||||
|
result.interfaces_created += 1
|
||||||
|
self.logger.info("Created interface %s on %s", interface_data.name, nb_vm.name)
|
||||||
|
else:
|
||||||
|
result.interfaces_updated += 1
|
||||||
|
self.logger.info("Updated interface %s on %s", interface_data.name, nb_vm.name)
|
||||||
|
|
||||||
|
if not self.endpoint.sync_ip_addresses:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for raw_ip in interface_data.ip_addresses:
|
||||||
|
normalized = self._normalize_ip_address(raw_ip)
|
||||||
|
if normalized is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
address, family = normalized
|
||||||
|
ip_obj = self._sync_ip_address(address, vm_interface, result)
|
||||||
|
if ip_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if family == 4 and primary_ipv4 is None:
|
||||||
|
primary_ipv4 = ip_obj
|
||||||
|
if family == 6 and primary_ipv6 is None:
|
||||||
|
primary_ipv6 = ip_obj
|
||||||
|
|
||||||
|
return primary_ipv4, primary_ipv6
|
||||||
|
|
||||||
|
def _normalize_ip_address(self, raw_ip):
|
||||||
|
if not raw_ip:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_ip = str(raw_ip).split("%", 1)[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "/" in raw_ip:
|
||||||
|
ip_interface = ipaddress.ip_interface(raw_ip)
|
||||||
|
ip_obj = ip_interface.ip
|
||||||
|
prefix_length = ip_interface.network.prefixlen
|
||||||
|
else:
|
||||||
|
ip_obj = ipaddress.ip_address(raw_ip)
|
||||||
|
prefix_length = (
|
||||||
|
self.endpoint.default_ipv4_prefix_length
|
||||||
|
if ip_obj.version == 4
|
||||||
|
else self.endpoint.default_ipv6_prefix_length
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_unspecified or ip_obj.is_multicast:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return f"{ip_obj.compressed}/{prefix_length}", ip_obj.version
|
||||||
|
|
||||||
|
def _sync_ip_address(self, address, vm_interface, result):
|
||||||
|
existing_ip = IPAddress.objects.filter(address=address).first()
|
||||||
|
|
||||||
|
if existing_ip and not self._may_update_ip(existing_ip, vm_interface):
|
||||||
|
result.ip_conflicts += 1
|
||||||
|
self.logger.warning(
|
||||||
|
"Skipped IP %s because it belongs to another tenant or object.",
|
||||||
|
address,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
created = existing_ip is None
|
||||||
|
ip_obj = existing_ip or IPAddress(address=address)
|
||||||
|
ip_obj.status = "active"
|
||||||
|
ip_obj.assigned_object = vm_interface
|
||||||
|
|
||||||
|
if hasattr(ip_obj, "tenant"):
|
||||||
|
ip_obj.tenant = self.endpoint.tenant
|
||||||
|
|
||||||
|
ip_obj.full_clean()
|
||||||
|
ip_obj.save()
|
||||||
|
|
||||||
|
if created:
|
||||||
|
result.ip_addresses_created += 1
|
||||||
|
self.logger.info("Created IP %s", address)
|
||||||
|
else:
|
||||||
|
result.ip_addresses_updated += 1
|
||||||
|
self.logger.info("Updated IP %s", address)
|
||||||
|
|
||||||
|
return ip_obj
|
||||||
|
|
||||||
|
def _may_update_ip(self, ip_obj, vm_interface):
|
||||||
|
assigned_to_this_interface = (
|
||||||
|
ip_obj.assigned_object_type_id == self.interface_content_type.id
|
||||||
|
and ip_obj.assigned_object_id == vm_interface.pk
|
||||||
|
)
|
||||||
|
|
||||||
|
if assigned_to_this_interface:
|
||||||
|
return True
|
||||||
|
|
||||||
|
ip_tenant_id = getattr(ip_obj, "tenant_id", None)
|
||||||
|
if self.endpoint.tenant_id and ip_tenant_id and ip_tenant_id != self.endpoint.tenant_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if ip_obj.assigned_object_id and not assigned_to_this_interface:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _set_primary_ips(self, nb_vm, primary_ipv4, primary_ipv6):
|
||||||
|
if not self.endpoint.sync_primary_ips:
|
||||||
|
return
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
if primary_ipv4 is not None:
|
||||||
|
nb_vm.primary_ip4 = primary_ipv4
|
||||||
|
update_fields.append("primary_ip4")
|
||||||
|
if primary_ipv6 is not None:
|
||||||
|
nb_vm.primary_ip6 = primary_ipv6
|
||||||
|
update_fields.append("primary_ip6")
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
nb_vm.full_clean()
|
||||||
|
nb_vm.save(update_fields=update_fields)
|
||||||
|
self.logger.info("Updated primary IPs for %s", nb_vm.name)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import django_tables2 as tables
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from netbox.tables import NetBoxTable
|
||||||
|
from netbox.tables.columns import BooleanColumn, ChoiceFieldColumn
|
||||||
|
|
||||||
|
from .models import VCenterEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
class VCenterEndpointTable(NetBoxTable):
|
||||||
|
name = tables.Column(
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
|
enabled = BooleanColumn()
|
||||||
|
tenant = tables.Column(
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
|
site = tables.Column(
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
|
cluster = tables.Column(
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
|
last_status = ChoiceFieldColumn(
|
||||||
|
verbose_name=_("Last status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = VCenterEndpoint
|
||||||
|
fields = (
|
||||||
|
"pk",
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"enabled",
|
||||||
|
"host",
|
||||||
|
"tenant",
|
||||||
|
"site",
|
||||||
|
"cluster",
|
||||||
|
"sync_interval_minutes",
|
||||||
|
"next_sync_at",
|
||||||
|
"last_status",
|
||||||
|
"last_sync_at",
|
||||||
|
"last_success_at",
|
||||||
|
)
|
||||||
|
default_columns = (
|
||||||
|
"pk",
|
||||||
|
"name",
|
||||||
|
"enabled",
|
||||||
|
"host",
|
||||||
|
"tenant",
|
||||||
|
"cluster",
|
||||||
|
"last_status",
|
||||||
|
"last_sync_at",
|
||||||
|
)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
{% extends 'generic/object.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">vCenter</h5>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Name</th>
|
||||||
|
<td>{{ object.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Host</th>
|
||||||
|
<td>{{ object.host }}:{{ object.port }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Username</th>
|
||||||
|
<td>{{ object.username }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Enabled</th>
|
||||||
|
<td>{{ object.enabled|yesno:"Yes,No" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Validate SSL</th>
|
||||||
|
<td>{{ object.validate_ssl|yesno:"Yes,No" }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">NetBox Target</h5>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Tenant</th>
|
||||||
|
<td>{{ object.tenant|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Site</th>
|
||||||
|
<td>{{ object.site|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Cluster</th>
|
||||||
|
<td>{{ object.cluster }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Sync interval</th>
|
||||||
|
<td>{{ object.sync_interval_minutes|default:"Manual only" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Next sync</th>
|
||||||
|
<td>{{ object.next_sync_at|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">Sync</h5>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Last status</th>
|
||||||
|
<td>{{ object.get_last_status_display }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Last sync</th>
|
||||||
|
<td>{{ object.last_sync_at|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Last success</th>
|
||||||
|
<td>{{ object.last_success_at|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">VMs</th>
|
||||||
|
<td>{{ object.last_vm_count }} total, {{ object.last_created_count }} created, {{ object.last_updated_count }} updated, {{ object.last_error_count }} errors</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Message</th>
|
||||||
|
<td>{{ object.last_message|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% if perms.netbox_vmware_importer.change_vcenterendpoint %}
|
||||||
|
<div class="card-footer">
|
||||||
|
<form method="post" action="{% url 'plugins:netbox_vmware_importer:vcenterendpoint_sync' pk=object.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="mdi mdi-sync"></i> Sync jetzt starten
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">Filters</h5>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Include regex</th>
|
||||||
|
<td>{{ object.include_name_regex|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Exclude regex</th>
|
||||||
|
<td>{{ object.exclude_name_regex|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Powered off VMs</th>
|
||||||
|
<td>{{ object.sync_powered_off|yesno:"Synced,Skipped" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Interfaces/IPs</th>
|
||||||
|
<td>{{ object.sync_interfaces|yesno:"Interfaces on,Interfaces off" }} / {{ object.sync_ip_addresses|yesno:"IPs on,IPs off" }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if object.comments %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">Comments</h5>
|
||||||
|
<div class="card-body rendered-markdown">
|
||||||
|
{{ object.comments|linebreaksbr }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = (
|
||||||
|
path("vcenters/", views.VCenterEndpointListView.as_view(), name="vcenterendpoint_list"),
|
||||||
|
path("vcenters/add/", views.VCenterEndpointEditView.as_view(), name="vcenterendpoint_add"),
|
||||||
|
path("vcenters/<int:pk>/", views.VCenterEndpointView.as_view(), name="vcenterendpoint"),
|
||||||
|
path("vcenters/<int:pk>/edit/", views.VCenterEndpointEditView.as_view(), name="vcenterendpoint_edit"),
|
||||||
|
path("vcenters/<int:pk>/delete/", views.VCenterEndpointDeleteView.as_view(), name="vcenterendpoint_delete"),
|
||||||
|
path("vcenters/<int:pk>/sync/", views.VCenterEndpointSyncView.as_view(), name="vcenterendpoint_sync"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from django.contrib import messages
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views import View
|
||||||
|
from netbox.views import generic
|
||||||
|
|
||||||
|
from .filtersets import VCenterEndpointFilterSet
|
||||||
|
from .forms import VCenterEndpointFilterForm, VCenterEndpointForm
|
||||||
|
from .jobs import SyncVCenterEndpointJob
|
||||||
|
from .models import VCenterEndpoint
|
||||||
|
from .tables import VCenterEndpointTable
|
||||||
|
|
||||||
|
|
||||||
|
class VCenterEndpointListView(generic.ObjectListView):
|
||||||
|
queryset = VCenterEndpoint.objects.all()
|
||||||
|
table = VCenterEndpointTable
|
||||||
|
filterset = VCenterEndpointFilterSet
|
||||||
|
filterset_form = VCenterEndpointFilterForm
|
||||||
|
|
||||||
|
|
||||||
|
class VCenterEndpointView(generic.ObjectView):
|
||||||
|
queryset = VCenterEndpoint.objects.all()
|
||||||
|
|
||||||
|
def get_extra_context(self, request, instance):
|
||||||
|
return {
|
||||||
|
"latest_jobs": instance.get_latest_jobs()[:10],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VCenterEndpointEditView(generic.ObjectEditView):
|
||||||
|
queryset = VCenterEndpoint.objects.all()
|
||||||
|
form = VCenterEndpointForm
|
||||||
|
|
||||||
|
|
||||||
|
class VCenterEndpointDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = VCenterEndpoint.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class VCenterEndpointSyncView(View):
|
||||||
|
def post(self, request, pk):
|
||||||
|
endpoint = get_object_or_404(VCenterEndpoint, pk=pk)
|
||||||
|
|
||||||
|
if not request.user.has_perm("netbox_vmware_importer.change_vcenterendpoint"):
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
|
SyncVCenterEndpointJob.enqueue(instance=endpoint)
|
||||||
|
endpoint.mark_queued()
|
||||||
|
messages.success(request, _("VMware sync job queued for %(endpoint)s.") % {"endpoint": endpoint})
|
||||||
|
|
||||||
|
return redirect(endpoint)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "netbox-vmware-importer"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "NetBox plugin to synchronize VMware vSphere virtual machines into NetBox."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [
|
||||||
|
{name = "Internal NetBox Team"}
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Framework :: Django",
|
||||||
|
"Intended Audience :: System Administrators",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"netbox>=4.4,<4.6",
|
||||||
|
"pyvmomi>=8.0.3",
|
||||||
|
"cryptography>=42.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["netbox_vmware_importer*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
netbox_vmware_importer = ["templates/**/*.html"]
|
||||||
Reference in New Issue
Block a user