diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
new file mode 100644
index 0000000..d758b39
--- /dev/null
+++ b/COMPATIBILITY.md
@@ -0,0 +1,5 @@
+# Compatibility Matrix
+
+| Plugin Release | Minimum NetBox Version | Maximum NetBox Version |
+|----------------|------------------------|------------------------|
+| 0.1.0 | 4.4.0 | 4.5.x |
diff --git a/alt_app.py b/alt_app.py
new file mode 100644
index 0000000..f762a25
--- /dev/null
+++ b/alt_app.py
@@ -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()
\ No newline at end of file
diff --git a/netbox_vmware_importer/__init__.py b/netbox_vmware_importer/__init__.py
new file mode 100644
index 0000000..131af39
--- /dev/null
+++ b/netbox_vmware_importer/__init__.py
@@ -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
diff --git a/netbox_vmware_importer/choices.py b/netbox_vmware_importer/choices.py
new file mode 100644
index 0000000..34ef047
--- /dev/null
+++ b/netbox_vmware_importer/choices.py
@@ -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"),
+ ]
diff --git a/netbox_vmware_importer/crypto.py b/netbox_vmware_importer/crypto.py
new file mode 100644
index 0000000..4cb2df8
--- /dev/null
+++ b/netbox_vmware_importer/crypto.py
@@ -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
diff --git a/netbox_vmware_importer/filtersets.py b/netbox_vmware_importer/filtersets.py
new file mode 100644
index 0000000..07ce34a
--- /dev/null
+++ b/netbox_vmware_importer/filtersets.py
@@ -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)
+ )
diff --git a/netbox_vmware_importer/forms.py b/netbox_vmware_importer/forms.py
new file mode 100644
index 0000000..90f836a
--- /dev/null
+++ b/netbox_vmware_importer/forms.py
@@ -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")),
+ )
diff --git a/netbox_vmware_importer/jobs.py b/netbox_vmware_importer/jobs.py
new file mode 100644
index 0000000..e3bd92a
--- /dev/null
+++ b/netbox_vmware_importer/jobs.py
@@ -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)
diff --git a/netbox_vmware_importer/migrations/0001_initial.py b/netbox_vmware_importer/migrations/0001_initial.py
new file mode 100644
index 0000000..e1418b3
--- /dev/null
+++ b/netbox_vmware_importer/migrations/0001_initial.py
@@ -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",),
+ },
+ ),
+ ]
diff --git a/netbox_vmware_importer/migrations/__init__.py b/netbox_vmware_importer/migrations/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/netbox_vmware_importer/migrations/__init__.py
@@ -0,0 +1 @@
+
diff --git a/netbox_vmware_importer/models.py b/netbox_vmware_importer/models.py
new file mode 100644
index 0000000..1780aa3
--- /dev/null
+++ b/netbox_vmware_importer/models.py
@@ -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)
diff --git a/netbox_vmware_importer/navigation.py b/netbox_vmware_importer/navigation.py
new file mode 100644
index 0000000..5feec7a
--- /dev/null
+++ b/netbox_vmware_importer/navigation.py
@@ -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",
+)
diff --git a/netbox_vmware_importer/sync.py b/netbox_vmware_importer/sync.py
new file mode 100644
index 0000000..c3d926f
--- /dev/null
+++ b/netbox_vmware_importer/sync.py
@@ -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)
diff --git a/netbox_vmware_importer/tables.py b/netbox_vmware_importer/tables.py
new file mode 100644
index 0000000..ff02938
--- /dev/null
+++ b/netbox_vmware_importer/tables.py
@@ -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",
+ )
diff --git a/netbox_vmware_importer/templates/netbox_vmware_importer/vcenterendpoint.html b/netbox_vmware_importer/templates/netbox_vmware_importer/vcenterendpoint.html
new file mode 100644
index 0000000..982f7a2
--- /dev/null
+++ b/netbox_vmware_importer/templates/netbox_vmware_importer/vcenterendpoint.html
@@ -0,0 +1,136 @@
+{% extends 'generic/object.html' %}
+
+{% block content %}
+
+
+
+
+
+
+ | Name |
+ {{ object.name }} |
+
+
+ | Host |
+ {{ object.host }}:{{ object.port }} |
+
+
+ | Username |
+ {{ object.username }} |
+
+
+ | Enabled |
+ {{ object.enabled|yesno:"Yes,No" }} |
+
+
+ | Validate SSL |
+ {{ object.validate_ssl|yesno:"Yes,No" }} |
+
+
+
+
+
+
+
+
+
+ | Tenant |
+ {{ object.tenant|default:"-" }} |
+
+
+ | Site |
+ {{ object.site|default:"-" }} |
+
+
+ | Cluster |
+ {{ object.cluster }} |
+
+
+ | Sync interval |
+ {{ object.sync_interval_minutes|default:"Manual only" }} |
+
+
+ | Next sync |
+ {{ object.next_sync_at|default:"-" }} |
+
+
+
+
+
+
+
+
+
+
+
+
+ | Last status |
+ {{ object.get_last_status_display }} |
+
+
+ | Last sync |
+ {{ object.last_sync_at|default:"-" }} |
+
+
+ | Last success |
+ {{ object.last_success_at|default:"-" }} |
+
+
+ | VMs |
+ {{ object.last_vm_count }} total, {{ object.last_created_count }} created, {{ object.last_updated_count }} updated, {{ object.last_error_count }} errors |
+
+
+ | Message |
+ {{ object.last_message|default:"-" }} |
+
+
+ {% if perms.netbox_vmware_importer.change_vcenterendpoint %}
+
+ {% endif %}
+
+
+
+
+
+
+
+ | Include regex |
+ {{ object.include_name_regex|default:"-" }} |
+
+
+ | Exclude regex |
+ {{ object.exclude_name_regex|default:"-" }} |
+
+
+ | Powered off VMs |
+ {{ object.sync_powered_off|yesno:"Synced,Skipped" }} |
+
+
+ | Interfaces/IPs |
+ {{ object.sync_interfaces|yesno:"Interfaces on,Interfaces off" }} / {{ object.sync_ip_addresses|yesno:"IPs on,IPs off" }} |
+
+
+
+
+
+
+{% if object.comments %}
+
+
+
+
+
+ {{ object.comments|linebreaksbr }}
+
+
+
+
+{% endif %}
+{% endblock content %}
diff --git a/netbox_vmware_importer/urls.py b/netbox_vmware_importer/urls.py
new file mode 100644
index 0000000..e4ef7e4
--- /dev/null
+++ b/netbox_vmware_importer/urls.py
@@ -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//", views.VCenterEndpointView.as_view(), name="vcenterendpoint"),
+ path("vcenters//edit/", views.VCenterEndpointEditView.as_view(), name="vcenterendpoint_edit"),
+ path("vcenters//delete/", views.VCenterEndpointDeleteView.as_view(), name="vcenterendpoint_delete"),
+ path("vcenters//sync/", views.VCenterEndpointSyncView.as_view(), name="vcenterendpoint_sync"),
+)
diff --git a/netbox_vmware_importer/views.py b/netbox_vmware_importer/views.py
new file mode 100644
index 0000000..bf1ed68
--- /dev/null
+++ b/netbox_vmware_importer/views.py
@@ -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)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..45402ae
--- /dev/null
+++ b/pyproject.toml
@@ -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"]