Sync MAC addresses and schedule endpoint sync jobs

This commit is contained in:
2026-07-10 10:42:44 +02:00
parent 58af2bde5d
commit 36f158b108
7 changed files with 142 additions and 16 deletions
+1
View File
@@ -10,3 +10,4 @@
| 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 |
| 0.1.8 | 4.4.0 | 4.6.x |
+3
View File
@@ -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
- MAC-Adressen werden als NetBox MAC Address Objekte am VM-Interface angelegt und als primaere MAC gesetzt
- 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`
@@ -50,6 +51,8 @@ sudo systemctl restart netbox netbox-rq
Wenn `Sync interval minutes` gesetzt ist, prueft ein Systemjob alle fuenf Minuten, welche Endpoints faellig sind, und stellt die eigentlichen Sync-Jobs in die Queue.
Ab Version `0.1.8` wird beim Speichern eines Endpoints zusaetzlich ein wiederkehrender NetBox-Job fuer genau diesen Endpoint geplant. Nach einem Update vorhandene Endpoints einmal speichern oder `netbox-rq` neu starten und bis zum naechsten Systemjob-Lauf warten.
## Sicherheit
Das vCenter-Passwort wird verschluesselt in der Plugin-Tabelle gespeichert. Der Schluessel wird aus `SECRET_KEY` abgeleitet. Wenn `SECRET_KEY` rotiert wird, muessen die vCenter-Passwoerter in den Endpoints erneut gesetzt werden.
+2 -1
View File
@@ -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.7"
version = "0.1.8"
author = "Internal NetBox Team"
base_url = "vmware-importer"
min_version = "4.4.0"
@@ -15,6 +15,7 @@ class VMwareImporterConfig(PluginConfig):
# Importing registers the scheduler system job with NetBox.
from . import jobs # noqa: F401
from . import signals # noqa: F401
config = VMwareImporterConfig
+39 -12
View File
@@ -1,3 +1,4 @@
from core.choices import JobStatusChoices
from core.exceptions import JobFailed
from django.utils import timezone
from netbox.jobs import JobRunner, system_job
@@ -7,6 +8,29 @@ from .models import VCenterEndpoint
from .sync import VMwareImporter
def clear_endpoint_sync_schedule(endpoint):
return SyncVCenterEndpointJob.get_jobs(endpoint).filter(
status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
interval__isnull=False,
).delete()
def schedule_endpoint_sync(endpoint):
if not endpoint.enabled or not endpoint.sync_interval_minutes:
clear_endpoint_sync_schedule(endpoint)
return None
if not endpoint.next_sync_at:
endpoint.next_sync_at = timezone.now()
VCenterEndpoint.objects.filter(pk=endpoint.pk).update(next_sync_at=endpoint.next_sync_at)
return SyncVCenterEndpointJob.enqueue_once(
instance=endpoint,
schedule_at=endpoint.next_sync_at,
interval=endpoint.sync_interval_minutes,
)
class SyncVCenterEndpointJob(JobRunner):
class Meta:
name = "VMware VM synchronization"
@@ -59,7 +83,8 @@ class SyncVCenterEndpointJob(JobRunner):
self.logger.info(
(
"VMware sync finished for %s: %s VM(s), %s created, %s updated, %s skipped; "
"virtual disks: %s created, %s updated, %s deleted."
"virtual disks: %s created, %s updated, %s deleted; "
"MAC addresses: %s created, %s updated, %s deleted."
),
endpoint.name,
result.synced,
@@ -69,6 +94,9 @@ class SyncVCenterEndpointJob(JobRunner):
result.virtual_disks_created,
result.virtual_disks_updated,
result.virtual_disks_deleted,
result.mac_addresses_created,
result.mac_addresses_updated,
result.mac_addresses_deleted,
)
@@ -79,18 +107,17 @@ class ScheduleDueVCenterSyncsJob(JobRunner):
def run(self, *args, **kwargs):
now = timezone.now()
due_endpoints = VCenterEndpoint.objects.filter(
enabled_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
scheduled = 0
for endpoint in enabled_endpoints:
if endpoint.next_sync_at is None:
endpoint.next_sync_at = now
endpoint.save(update_fields=("next_sync_at", "last_updated"))
if schedule_endpoint_sync(endpoint):
scheduled += 1
self.logger.info("Queued %s VMware sync job(s).", queued)
self.logger.info("Ensured %s VMware endpoint sync schedule(s).", scheduled)
+25
View File
@@ -0,0 +1,25 @@
from django.db import transaction
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from .jobs import clear_endpoint_sync_schedule, schedule_endpoint_sync
from .models import VCenterEndpoint
AUTOMATIC_SYNC_FIELDS = {"enabled", "sync_interval_minutes"}
@receiver(post_save, sender=VCenterEndpoint)
def schedule_vcenter_endpoint_sync(sender, instance, raw=False, update_fields=None, **kwargs):
if raw:
return
if update_fields is not None and not AUTOMATIC_SYNC_FIELDS.intersection(update_fields):
return
transaction.on_commit(lambda: schedule_endpoint_sync(instance))
@receiver(pre_delete, sender=VCenterEndpoint)
def clear_vcenter_endpoint_sync(sender, instance, **kwargs):
clear_endpoint_sync_schedule(instance)
+71 -2
View File
@@ -6,7 +6,7 @@ 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 dcim.models import MACAddress, Platform
from ipam.models import IPAddress
from virtualization.models import VMInterface, VirtualDisk, VirtualMachine
@@ -58,6 +58,9 @@ class SyncResult:
ip_addresses_created: int = 0
ip_addresses_updated: int = 0
ip_conflicts: int = 0
mac_addresses_created: int = 0
mac_addresses_updated: int = 0
mac_addresses_deleted: int = 0
virtual_disks_created: int = 0
virtual_disks_updated: int = 0
virtual_disks_deleted: int = 0
@@ -214,11 +217,13 @@ class VMwareImporter:
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
self.mac_address_content_type = None
def sync(self):
result = SyncResult()
platforms = list(Platform.objects.all())
self.interface_content_type = ContentType.objects.get_for_model(VMInterface)
self.mac_address_content_type = ContentType.objects.get_for_model(VMInterface)
with VMwareClient(self.endpoint) as client:
for vm_data in client.iter_virtual_machines():
@@ -366,7 +371,6 @@ class VMwareImporter:
name=interface_data.name,
)
vm_interface.mac_address = interface_data.mac_address
vm_interface.enabled = True
vm_interface.full_clean()
vm_interface.save()
@@ -378,6 +382,8 @@ class VMwareImporter:
result.interfaces_updated += 1
self.logger.info("Updated interface %s on %s", interface_data.name, nb_vm.name)
self._sync_mac_address(vm_interface, interface_data.mac_address, result)
if not self.endpoint.sync_ip_addresses:
continue
@@ -398,6 +404,69 @@ class VMwareImporter:
return primary_ipv4, primary_ipv6
def _sync_mac_address(self, vm_interface, mac_address, result):
normalized_mac = self._normalize_mac_address(mac_address)
if normalized_mac is None:
return None
mac_obj = MACAddress.objects.filter(
assigned_object_type=self.mac_address_content_type,
assigned_object_id=vm_interface.pk,
mac_address=normalized_mac,
).first()
created = mac_obj is None
if created:
mac_obj = MACAddress(
mac_address=normalized_mac,
assigned_object=vm_interface,
)
mac_obj.description = "VMware reported MAC address"
mac_obj.full_clean()
mac_obj.save()
if created:
result.mac_addresses_created += 1
self.logger.info("Created MAC address %s on interface %s", normalized_mac, vm_interface.name)
else:
result.mac_addresses_updated += 1
self.logger.info("Updated MAC address %s on interface %s", normalized_mac, vm_interface.name)
if vm_interface.primary_mac_address_id != mac_obj.pk:
vm_interface.primary_mac_address = mac_obj
vm_interface.full_clean()
vm_interface.save(update_fields=("primary_mac_address",))
self.logger.info("Set primary MAC address %s on interface %s", normalized_mac, vm_interface.name)
stale_macs = MACAddress.objects.filter(
assigned_object_type=self.mac_address_content_type,
assigned_object_id=vm_interface.pk,
description="VMware reported MAC address",
).exclude(pk=mac_obj.pk)
stale_count = stale_macs.count()
if stale_count:
stale_macs.delete()
result.mac_addresses_deleted += stale_count
self.logger.info("Deleted %s stale VMware MAC address(es) from interface %s", stale_count, vm_interface.name)
return mac_obj
@staticmethod
def _normalize_mac_address(mac_address):
if not mac_address:
return None
value = str(mac_address).strip().replace("-", ":").lower()
parts = value.split(":")
if len(parts) != 6:
return None
try:
return ":".join(f"{int(part, 16):02x}" for part in parts)
except ValueError:
return None
def _normalize_ip_address(self, raw_ip):
if not raw_ip:
return None
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-vmware-importer"
version = "0.1.7"
version = "0.1.8"
description = "NetBox plugin to synchronize VMware vSphere virtual machines into NetBox."
readme = "README.md"
requires-python = ">=3.12"