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 %} +
+
+
+
vCenter
+ + + + + + + + + + + + + + + + + + + + + +
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" }}
+
+
+
+
+
NetBox Target
+ + + + + + + + + + + + + + + + + + + + + +
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:"-" }}
+
+
+
+ +
+
+
+
Sync
+ + + + + + + + + + + + + + + + + + + + + +
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 %} +
+
+
+
+
Filters
+ + + + + + + + + + + + + + + + + +
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 %} +
+
+
+
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"]