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.5 | 4.4.0 | 4.6.x |
| 0.1.6 | 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.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 - Manuelle Synchronisation per Button
- Optionale automatische Synchronisation ueber ein Minutenintervall - Optionale automatische Synchronisation ueber ein Minutenintervall
- Import von VM-Name, Status, vCPU, RAM, Disk, Plattform, Interfaces, MAC-Adressen und IPs - 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 - 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 - Primaere IPv4/IPv6 wird anhand der ersten gefundenen Gast-IP gesetzt
- Multi-Tenant-sicherer VM-Abgleich ueber `name + cluster` - 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. 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 ## 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. 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" name = "netbox_vmware_importer"
verbose_name = "VMware Importer" verbose_name = "VMware Importer"
description = "Synchronize VMware vSphere virtual machines into NetBox." description = "Synchronize VMware vSphere virtual machines into NetBox."
version = "0.1.7" version = "0.1.8"
author = "Internal NetBox Team" author = "Internal NetBox Team"
base_url = "vmware-importer" base_url = "vmware-importer"
min_version = "4.4.0" min_version = "4.4.0"
@@ -15,6 +15,7 @@ class VMwareImporterConfig(PluginConfig):
# Importing registers the scheduler system job with NetBox. # Importing registers the scheduler system job with NetBox.
from . import jobs # noqa: F401 from . import jobs # noqa: F401
from . import signals # noqa: F401
config = VMwareImporterConfig config = VMwareImporterConfig
+39 -12
View File
@@ -1,3 +1,4 @@
from core.choices import JobStatusChoices
from core.exceptions import JobFailed from core.exceptions import JobFailed
from django.utils import timezone from django.utils import timezone
from netbox.jobs import JobRunner, system_job from netbox.jobs import JobRunner, system_job
@@ -7,6 +8,29 @@ from .models import VCenterEndpoint
from .sync import VMwareImporter 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 SyncVCenterEndpointJob(JobRunner):
class Meta: class Meta:
name = "VMware VM synchronization" name = "VMware VM synchronization"
@@ -59,7 +83,8 @@ class SyncVCenterEndpointJob(JobRunner):
self.logger.info( 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." "virtual disks: %s created, %s updated, %s deleted; "
"MAC addresses: %s created, %s updated, %s deleted."
), ),
endpoint.name, endpoint.name,
result.synced, result.synced,
@@ -69,6 +94,9 @@ class SyncVCenterEndpointJob(JobRunner):
result.virtual_disks_created, result.virtual_disks_created,
result.virtual_disks_updated, result.virtual_disks_updated,
result.virtual_disks_deleted, 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): def run(self, *args, **kwargs):
now = timezone.now() now = timezone.now()
due_endpoints = VCenterEndpoint.objects.filter( enabled_endpoints = VCenterEndpoint.objects.filter(
enabled=True, enabled=True,
sync_interval_minutes__isnull=False, sync_interval_minutes__isnull=False,
).filter(next_sync_at__lte=now) )
queued = 0 scheduled = 0
for endpoint in due_endpoints: for endpoint in enabled_endpoints:
SyncVCenterEndpointJob.enqueue_once(instance=endpoint) if endpoint.next_sync_at is None:
endpoint.last_status = SyncStatusChoices.STATUS_QUEUED endpoint.next_sync_at = now
endpoint.last_message = "Automatic sync job queued." endpoint.save(update_fields=("next_sync_at", "last_updated"))
endpoint.set_next_sync(now) if schedule_endpoint_sync(endpoint):
endpoint.save(update_fields=("last_status", "last_message", "next_sync_at", "last_updated")) scheduled += 1
queued += 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.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
from django.db.models import Sum from django.db.models import Sum
from dcim.models import Platform from dcim.models import MACAddress, Platform
from ipam.models import IPAddress from ipam.models import IPAddress
from virtualization.models import VMInterface, VirtualDisk, VirtualMachine from virtualization.models import VMInterface, VirtualDisk, VirtualMachine
@@ -58,6 +58,9 @@ class SyncResult:
ip_addresses_created: int = 0 ip_addresses_created: int = 0
ip_addresses_updated: int = 0 ip_addresses_updated: int = 0
ip_conflicts: 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_created: int = 0
virtual_disks_updated: int = 0 virtual_disks_updated: int = 0
virtual_disks_deleted: 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.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.exclude_pattern = re.compile(endpoint.exclude_name_regex) if endpoint.exclude_name_regex else None
self.interface_content_type = None self.interface_content_type = None
self.mac_address_content_type = None
def sync(self): def sync(self):
result = SyncResult() result = SyncResult()
platforms = list(Platform.objects.all()) platforms = list(Platform.objects.all())
self.interface_content_type = ContentType.objects.get_for_model(VMInterface) 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: with VMwareClient(self.endpoint) as client:
for vm_data in client.iter_virtual_machines(): for vm_data in client.iter_virtual_machines():
@@ -366,7 +371,6 @@ class VMwareImporter:
name=interface_data.name, name=interface_data.name,
) )
vm_interface.mac_address = interface_data.mac_address
vm_interface.enabled = True vm_interface.enabled = True
vm_interface.full_clean() vm_interface.full_clean()
vm_interface.save() vm_interface.save()
@@ -378,6 +382,8 @@ class VMwareImporter:
result.interfaces_updated += 1 result.interfaces_updated += 1
self.logger.info("Updated interface %s on %s", interface_data.name, nb_vm.name) 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: if not self.endpoint.sync_ip_addresses:
continue continue
@@ -398,6 +404,69 @@ class VMwareImporter:
return primary_ipv4, primary_ipv6 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): def _normalize_ip_address(self, raw_ip):
if not raw_ip: if not raw_ip:
return None return None
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-vmware-importer" name = "netbox-vmware-importer"
version = "0.1.7" version = "0.1.8"
description = "NetBox plugin to synchronize VMware vSphere virtual machines into NetBox." description = "NetBox plugin to synchronize VMware vSphere virtual machines into NetBox."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"