Sync MAC addresses and schedule endpoint sync jobs
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user