From 36f158b10848d4a56897917fb269dd69569d6f27 Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 10 Jul 2026 10:42:44 +0200 Subject: [PATCH] Sync MAC addresses and schedule endpoint sync jobs --- COMPATIBILITY.md | 1 + README.md | 3 ++ netbox_vmware_importer/__init__.py | 3 +- netbox_vmware_importer/jobs.py | 51 ++++++++++++++++----- netbox_vmware_importer/signals.py | 25 ++++++++++ netbox_vmware_importer/sync.py | 73 +++++++++++++++++++++++++++++- pyproject.toml | 2 +- 7 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 netbox_vmware_importer/signals.py diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 7d10999..dff731b 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -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 | diff --git a/README.md b/README.md index 2ef2e7b..1f8239e 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 +- 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. diff --git a/netbox_vmware_importer/__init__.py b/netbox_vmware_importer/__init__.py index 1d5bf65..b81242b 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.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 diff --git a/netbox_vmware_importer/jobs.py b/netbox_vmware_importer/jobs.py index aa0aac6..b6ce8ee 100644 --- a/netbox_vmware_importer/jobs.py +++ b/netbox_vmware_importer/jobs.py @@ -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) diff --git a/netbox_vmware_importer/signals.py b/netbox_vmware_importer/signals.py new file mode 100644 index 0000000..fcd79cc --- /dev/null +++ b/netbox_vmware_importer/signals.py @@ -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) diff --git a/netbox_vmware_importer/sync.py b/netbox_vmware_importer/sync.py index ed953c0..684242c 100644 --- a/netbox_vmware_importer/sync.py +++ b/netbox_vmware_importer/sync.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index a540e63..913590d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"