From 58af2bde5d0811568ea99445d1cc3022ef30b595 Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 10 Jul 2026 10:32:03 +0200 Subject: [PATCH] Sync VMware virtual disks into NetBox --- COMPATIBILITY.md | 1 + README.md | 1 + netbox_vmware_importer/__init__.py | 2 +- netbox_vmware_importer/jobs.py | 8 +- netbox_vmware_importer/sync.py | 128 +++++++++++++++++++++++++++-- pyproject.toml | 2 +- 6 files changed, 132 insertions(+), 10 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index ee8ffd5..7d10999 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -9,3 +9,4 @@ | 0.1.4 | 4.4.0 | 4.6.x | | 0.1.5 | 4.4.0 | 4.6.x | | 0.1.6 | 4.4.0 | 4.6.x | +| 0.1.7 | 4.4.0 | 4.6.x | diff --git a/README.md b/README.md index 9b4440c..2ef2e7b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ NetBox plugin zum Synchronisieren von VMware vSphere/vCenter VMs nach NetBox. - Manuelle Synchronisation per Button - Optionale automatische Synchronisation ueber ein Minutenintervall - Import von VM-Name, Status, vCPU, RAM, Disk, Plattform, Interfaces, MAC-Adressen und IPs +- Virtuelle VMware-Festplatten werden als NetBox Virtual Disks mit Groesse und Backing-Info dokumentiert - Primaere IPv4/IPv6 wird anhand der ersten gefundenen Gast-IP gesetzt - Multi-Tenant-sicherer VM-Abgleich ueber `name + cluster` diff --git a/netbox_vmware_importer/__init__.py b/netbox_vmware_importer/__init__.py index b5b2237..1d5bf65 100644 --- a/netbox_vmware_importer/__init__.py +++ b/netbox_vmware_importer/__init__.py @@ -5,7 +5,7 @@ class VMwareImporterConfig(PluginConfig): name = "netbox_vmware_importer" verbose_name = "VMware Importer" description = "Synchronize VMware vSphere virtual machines into NetBox." - version = "0.1.6" + version = "0.1.7" author = "Internal NetBox Team" base_url = "vmware-importer" min_version = "4.4.0" diff --git a/netbox_vmware_importer/jobs.py b/netbox_vmware_importer/jobs.py index e3bd92a..aa0aac6 100644 --- a/netbox_vmware_importer/jobs.py +++ b/netbox_vmware_importer/jobs.py @@ -57,12 +57,18 @@ class SyncVCenterEndpointJob(JobRunner): endpoint.mark_success(result) self.logger.info( - "VMware sync finished for %s: %s VM(s), %s created, %s updated, %s skipped.", + ( + "VMware sync finished for %s: %s VM(s), %s created, %s updated, %s skipped; " + "virtual disks: %s created, %s updated, %s deleted." + ), endpoint.name, result.synced, result.created, result.updated, result.skipped, + result.virtual_disks_created, + result.virtual_disks_updated, + result.virtual_disks_deleted, ) diff --git a/netbox_vmware_importer/sync.py b/netbox_vmware_importer/sync.py index c3d926f..ed953c0 100644 --- a/netbox_vmware_importer/sync.py +++ b/netbox_vmware_importer/sync.py @@ -5,9 +5,10 @@ from dataclasses import dataclass, field from django.contrib.contenttypes.models import ContentType from django.db import transaction +from django.db.models import Sum from dcim.models import Platform from ipam.models import IPAddress -from virtualization.models import VMInterface, VirtualMachine +from virtualization.models import VMInterface, VirtualDisk, VirtualMachine try: from pyVim.connect import Disconnect, SmartConnect @@ -25,6 +26,13 @@ class InterfaceData: ip_addresses: list[str] = field(default_factory=list) +@dataclass +class DiskData: + name: str + size_mb: int + description: str = "" + + @dataclass class VMData: name: str @@ -33,6 +41,7 @@ class VMData: memory_mb: int disk_mb: int guest_os: str = "" + disks: list[DiskData] = field(default_factory=list) interfaces: list[InterfaceData] = field(default_factory=list) @@ -49,6 +58,9 @@ class SyncResult: ip_addresses_created: int = 0 ip_addresses_updated: int = 0 ip_conflicts: int = 0 + virtual_disks_created: int = 0 + virtual_disks_updated: int = 0 + virtual_disks_deleted: int = 0 class VMwareConnectionError(RuntimeError): @@ -101,7 +113,8 @@ class VMwareClient: 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) + disks = self._get_disks(hardware) + disk_mb = sum(disk.size_mb for disk in disks) 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" @@ -113,16 +126,64 @@ class VMwareClient: memory_mb=memory_mb, disk_mb=disk_mb, guest_os=guest_os, + disks=disks, interfaces=self._get_interfaces(vm_obj), ) @staticmethod - def _get_disk_size_mb(hardware): - disk_mb = 0 + def _get_disks(hardware): + disks = [] + used_names = set() + disk_index = 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 + disk_index += 1 + name = VMwareClient._unique_disk_name(device, disk_index, used_names) + disks.append( + DiskData( + name=name, + size_mb=round((device.capacityInKB or 0) / 1024), + description=VMwareClient._disk_description(device), + ) + ) + + return disks + + @staticmethod + def _unique_disk_name(device, disk_index, used_names): + device_info = getattr(device, "deviceInfo", None) + base_name = getattr(device_info, "label", None) or f"Hard disk {disk_index}" + base_name = VMwareClient._truncate_component_name(str(base_name).strip() or f"Hard disk {disk_index}") + + name = base_name + suffix = 2 + while name in used_names: + suffix_text = f" ({suffix})" + name = f"{base_name[:64 - len(suffix_text)]}{suffix_text}" + suffix += 1 + + used_names.add(name) + return name + + @staticmethod + def _truncate_component_name(value): + return value[:64] + + @staticmethod + def _disk_description(device): + backing = getattr(device, "backing", None) + filename = getattr(backing, "fileName", None) + thin_provisioned = getattr(backing, "thinProvisioned", None) + + parts = [] + if filename: + parts.append(str(filename)) + if thin_provisioned is not None: + parts.append("thin" if thin_provisioned else "thick") + + detail = ", ".join(parts) if parts else f"key={getattr(device, 'key', 'unknown')}" + return f"VMware: {detail}"[:200] @staticmethod def _get_interfaces(vm_obj): @@ -205,10 +266,12 @@ class VMwareImporter: 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) + if created or not nb_vm.virtualdisks.exists(): + nb_vm.disk = vm_data.disk_mb + nb_vm.full_clean() nb_vm.save() @@ -219,6 +282,8 @@ class VMwareImporter: result.updated += 1 self.logger.info("Updated VM %s", vm_data.name) + self._sync_virtual_disks(nb_vm, vm_data, result) + 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) @@ -235,6 +300,55 @@ class VMwareImporter: return None + def _sync_virtual_disks(self, nb_vm, vm_data, result): + if not vm_data.disks: + return + + synced_names = set() + + for disk_data in vm_data.disks: + synced_names.add(disk_data.name) + virtual_disk = VirtualDisk.objects.filter( + virtual_machine=nb_vm, + name=disk_data.name, + ).first() + created = virtual_disk is None + + if created: + virtual_disk = VirtualDisk( + virtual_machine=nb_vm, + name=disk_data.name, + ) + + virtual_disk.size = disk_data.size_mb + virtual_disk.description = disk_data.description + virtual_disk.full_clean() + virtual_disk.save() + + if created: + result.virtual_disks_created += 1 + self.logger.info("Created virtual disk %s on %s", disk_data.name, nb_vm.name) + else: + result.virtual_disks_updated += 1 + self.logger.info("Updated virtual disk %s on %s", disk_data.name, nb_vm.name) + + stale_disks = VirtualDisk.objects.filter( + virtual_machine=nb_vm, + description__startswith="VMware:", + ).exclude(name__in=synced_names) + stale_count = stale_disks.count() + if stale_count: + stale_disks.delete() + result.virtual_disks_deleted += stale_count + self.logger.info("Deleted %s stale VMware virtual disk(s) from %s", stale_count, nb_vm.name) + + disk_total = nb_vm.virtualdisks.aggregate(total=Sum("size", default=0))["total"] or vm_data.disk_mb + if nb_vm.disk != disk_total: + nb_vm.disk = disk_total + nb_vm.full_clean() + nb_vm.save(update_fields=("disk",)) + self.logger.info("Updated aggregate disk size for %s to %s MB", nb_vm.name, disk_total) + def _sync_interfaces(self, nb_vm, vm_data, result): primary_ipv4 = None primary_ipv6 = None diff --git a/pyproject.toml b/pyproject.toml index 2ef124e..a540e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-vmware-importer" -version = "0.1.6" +version = "0.1.7" description = "NetBox plugin to synchronize VMware vSphere virtual machines into NetBox." readme = "README.md" requires-python = ">=3.12"