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