Files
NetBox-VM-Import/netbox_vmware_importer/sync.py
T
2026-07-10 09:15:15 +02:00

378 lines
12 KiB
Python

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)