This commit is contained in:
2026-07-10 09:15:15 +02:00
parent b80f501d5a
commit 9d5ea53b5c
18 changed files with 2122 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Compatibility Matrix
| Plugin Release | Minimum NetBox Version | Maximum NetBox Version |
|----------------|------------------------|------------------------|
| 0.1.0 | 4.4.0 | 4.5.x |
+672
View File
@@ -0,0 +1,672 @@
import ssl
import atexit
import ipaddress
import tkinter as tk
from tkinter import ttk, messagebox
from pyVim.connect import SmartConnect, Disconnect
from pyVmomi import vim
import pynetbox
ssl._create_default_https_context = ssl._create_unverified_context
class ESXiNetBoxImporter:
def __init__(self, root):
self.root = root
self.root.title("ESXi -> NetBox VM Synchronizer")
self.root.geometry("950x750")
self.si = None
self.nb = None
self.vms = []
self.site_map = {}
self.cluster_map = {}
self.tenant_map = {}
self.platforms = []
self.create_gui()
# =========================================================
# GUI
# =========================================================
def create_gui(self):
main = ttk.Frame(self.root, padding=10)
main.pack(fill="both", expand=True)
# ============================================
# ESXi / vCenter
# ============================================
ttk.Label(main, text="ESXi / vCenter Host").grid(
row=0,
column=0,
sticky="w"
)
self.esxi_host = ttk.Entry(main, width=45)
self.esxi_host.grid(row=0, column=1, sticky="ew")
ttk.Label(main, text="Username").grid(
row=1,
column=0,
sticky="w"
)
self.esxi_user = ttk.Entry(main, width=45)
self.esxi_user.grid(row=1, column=1, sticky="ew")
ttk.Label(main, text="Password").grid(
row=2,
column=0,
sticky="w"
)
self.esxi_pass = ttk.Entry(main, width=45, show="*")
self.esxi_pass.grid(row=2, column=1, sticky="ew")
# ============================================
# NetBox
# ============================================
ttk.Label(main, text="NetBox URL").grid(
row=3,
column=0,
sticky="w"
)
self.nb_url = ttk.Entry(main, width=45)
self.nb_url.grid(row=3, column=1, sticky="ew")
ttk.Label(main, text="NetBox Token").grid(
row=4,
column=0,
sticky="w"
)
self.nb_token = ttk.Entry(main, width=45, show="*")
self.nb_token.grid(row=4, column=1, sticky="ew")
ttk.Button(
main,
text="Verbinden & Daten laden",
command=self.connect_all
).grid(
row=5,
column=1,
sticky="e",
pady=10
)
# ============================================
# Zielauswahl
# ============================================
ttk.Label(main, text="Site").grid(
row=6,
column=0,
sticky="w"
)
self.site_combo = ttk.Combobox(main, width=42)
self.site_combo.grid(row=6, column=1, sticky="ew")
ttk.Label(main, text="Cluster").grid(
row=7,
column=0,
sticky="w"
)
self.cluster_combo = ttk.Combobox(main, width=42)
self.cluster_combo.grid(row=7, column=1, sticky="ew")
ttk.Label(main, text="Tenant").grid(
row=8,
column=0,
sticky="w"
)
self.tenant_combo = ttk.Combobox(main, width=42)
self.tenant_combo.grid(row=8, column=1, sticky="ew")
# ============================================
# VM Liste
# ============================================
ttk.Label(main, text="Gefundene VMs").grid(
row=9,
column=0,
sticky="w",
pady=(15, 5)
)
self.vm_list = tk.Listbox(
main,
selectmode=tk.MULTIPLE,
width=100,
height=25
)
self.vm_list.grid(
row=10,
column=0,
columnspan=2,
sticky="nsew"
)
scrollbar = ttk.Scrollbar(
main,
orient="vertical",
command=self.vm_list.yview
)
scrollbar.grid(row=10, column=2, sticky="ns")
self.vm_list.config(yscrollcommand=scrollbar.set)
# ============================================
# Buttons
# ============================================
btn_frame = ttk.Frame(main)
btn_frame.grid(
row=11,
column=0,
columnspan=2,
pady=10,
sticky="e"
)
ttk.Button(
btn_frame,
text="Alle auswählen",
command=self.select_all
).pack(side="left", padx=5)
ttk.Button(
btn_frame,
text="Import / Sync starten",
command=self.import_vms
).pack(side="left", padx=5)
# ============================================
# Logfeld
# ============================================
ttk.Label(main, text="Log").grid(
row=12,
column=0,
sticky="w"
)
self.log_text = tk.Text(main, height=10)
self.log_text.grid(
row=13,
column=0,
columnspan=2,
sticky="nsew"
)
main.columnconfigure(1, weight=1)
main.rowconfigure(10, weight=1)
# =========================================================
# Logging
# =========================================================
def log(self, text):
self.log_text.insert(tk.END, f"{text}\n")
self.log_text.see(tk.END)
self.root.update()
# =========================================================
# Select All
# =========================================================
def select_all(self):
self.vm_list.select_set(0, tk.END)
# =========================================================
# Verbindungen
# =========================================================
def connect_all(self):
try:
self.connect_esxi()
self.connect_netbox()
self.load_netbox_data()
self.load_vms()
messagebox.showinfo(
"Erfolg",
"Verbindung erfolgreich hergestellt"
)
except Exception as e:
messagebox.showerror(
"Fehler",
str(e)
)
def connect_esxi(self):
self.log("Verbinde zu ESXi/vCenter...")
context = ssl._create_unverified_context()
self.si = SmartConnect(
host=self.esxi_host.get(),
user=self.esxi_user.get(),
pwd=self.esxi_pass.get(),
sslContext=context
)
atexit.register(Disconnect, self.si)
self.log("ESXi Verbindung erfolgreich")
def connect_netbox(self):
self.log("Verbinde zu NetBox...")
self.nb = pynetbox.api(
self.nb_url.get(),
token=self.nb_token.get()
)
self.log("NetBox Verbindung erfolgreich")
# =========================================================
# NetBox Daten
# =========================================================
def load_netbox_data(self):
self.log("Lade NetBox Daten...")
# Sites
sites = list(self.nb.dcim.sites.all())
self.site_map = {x.name: x.id for x in sites}
self.site_combo["values"] = list(self.site_map.keys())
# Cluster
clusters = list(self.nb.virtualization.clusters.all())
self.cluster_map = {x.name: x.id for x in clusters}
self.cluster_combo["values"] = list(self.cluster_map.keys())
# Tenant
tenants = list(self.nb.tenancy.tenants.all())
self.tenant_map = {x.name: x.id for x in tenants}
self.tenant_combo["values"] = list(self.tenant_map.keys())
# Plattformen
self.platforms = list(self.nb.dcim.platforms.all())
self.log("NetBox Daten geladen")
# =========================================================
# VMware VMs laden
# =========================================================
def load_vms(self):
self.log("Lade VMs aus VMware...")
content = self.si.RetrieveContent()
container = content.rootFolder
view_type = [vim.VirtualMachine]
recursive = True
container_view = content.viewManager.CreateContainerView(
container,
view_type,
recursive
)
self.vms = container_view.view
self.vm_list.delete(0, tk.END)
for vm in self.vms:
self.vm_list.insert(tk.END, vm.name)
self.log(f"{len(self.vms)} VMs gefunden")
# =========================================================
# Plattform Mapping
# =========================================================
def get_platform_id(self, guest_os):
if not guest_os:
return None
guest_os = guest_os.lower()
for platform in self.platforms:
if platform.name.lower() in guest_os:
return platform.id
return None
# =========================================================
# Prefix automatisch ergänzen
# =========================================================
def get_ip_with_prefix(self, ip):
try:
addr = ipaddress.ip_address(ip)
if addr.version == 4:
return f"{ip}/24"
return f"{ip}/64"
except:
return None
# =========================================================
# VM Import / Sync
# =========================================================
def import_vms(self):
selected = self.vm_list.curselection()
if not selected:
messagebox.showwarning(
"Hinweis",
"Keine VMs ausgewählt"
)
return
cluster_name = self.cluster_combo.get()
tenant_name = self.tenant_combo.get()
cluster_id = self.cluster_map.get(cluster_name)
tenant_id = self.tenant_map.get(tenant_name)
if not cluster_id:
messagebox.showerror(
"Fehler",
"Bitte Cluster auswählen"
)
return
for idx in selected:
vm = self.vms[idx]
self.log(f"Synchronisiere VM: {vm.name}")
# ============================================
# Hardwaredaten
# ============================================
memory = int(vm.config.hardware.memoryMB)
vcpus = int(vm.config.hardware.numCPU)
# ============================================
# Disk in MB (NetBox >= 4.x)
# ============================================
disk_size = 0
for dev in vm.config.hardware.device:
if isinstance(dev, vim.vm.device.VirtualDisk):
# VMware liefert KB
# NetBox erwartet MB
size_mb = dev.capacityInKB / 1024
disk_size += round(size_mb)
# ============================================
# Betriebssystem
# ============================================
guest_os = ""
try:
guest_os = vm.config.guestFullName
except:
pass
platform_id = self.get_platform_id(guest_os)
# ============================================
# Status
# ============================================
power_state = str(vm.runtime.powerState)
if "poweredOn" in power_state:
status = "active"
else:
status = "offline"
# ============================================
# VM Daten
# ============================================
vm_data = {
"name": vm.name,
"cluster": cluster_id,
"status": status,
"vcpus": vcpus,
"memory": memory,
"disk": disk_size,
"tenant": tenant_id,
"platform": platform_id
}
# ============================================
# VM erstellen / aktualisieren
# ============================================
nb_vm = self.nb.virtualization.virtual_machines.get(
name=vm.name
)
if nb_vm:
nb_vm.update(vm_data)
self.log(f"VM aktualisiert: {vm.name}")
else:
nb_vm = self.nb.virtualization.virtual_machines.create(
vm_data
)
self.log(f"VM erstellt: {vm.name}")
# ============================================
# Interfaces + IPs
# ============================================
primary_ipv4 = None
primary_ipv6 = None
if vm.guest and hasattr(vm.guest, "net"):
for net in vm.guest.net:
mac = getattr(net, "macAddress", None)
if not mac:
continue
interface_name = getattr(net, "device", None)
if not interface_name:
interface_name = f"NIC-{mac[-5:]}"
iface_data = {
"virtual_machine": nb_vm.id,
"name": interface_name,
"mac_address": mac,
"enabled": True
}
# Interface zuerst über Namen suchen
existing_iface = self.nb.virtualization.interfaces.get(
virtual_machine_id=nb_vm.id,
name=interface_name
)
if existing_iface:
# Falls MAC geändert wurde -> aktualisieren
existing_iface.update(iface_data)
vm_iface = existing_iface
self.log(
f"Interface aktualisiert: {interface_name}"
)
else:
vm_iface = self.nb.virtualization.interfaces.create(
iface_data
)
self.log(
f"Interface erstellt: {interface_name}"
)
self.log(f"Interface synchronisiert: {mac}")
# ============================================
# IPs
# ============================================
if hasattr(net, "ipAddress"):
for ip in net.ipAddress:
if ip.startswith("127."):
continue
if ip == "::1":
continue
address = self.get_ip_with_prefix(ip)
if not address:
continue
existing_ip = self.nb.ipam.ip_addresses.get(
address=address
)
ip_data = {
"address": address,
"status": "active",
"assigned_object_type": "virtualization.vminterface",
"assigned_object_id": vm_iface.id
}
if existing_ip:
existing_ip.update(ip_data)
ip_obj = existing_ip
self.log(f"IP aktualisiert: {address}")
else:
ip_obj = self.nb.ipam.ip_addresses.create(
ip_data
)
self.log(f"IP erstellt: {address}")
# ============================================
# Primary IP setzen
# ============================================
try:
parsed_ip = ipaddress.ip_address(ip)
if parsed_ip.version == 4:
if not primary_ipv4:
primary_ipv4 = ip_obj.id
else:
if not primary_ipv6:
primary_ipv6 = ip_obj.id
except:
pass
# ============================================
# Primäre IPs setzen
# ============================================
update_data = {}
if primary_ipv4:
update_data["primary_ip4"] = primary_ipv4
if primary_ipv6:
update_data["primary_ip6"] = primary_ipv6
if update_data:
nb_vm.update(update_data)
self.log(
f"Primary IPs gesetzt für {vm.name}"
)
messagebox.showinfo(
"Fertig",
"VM Import / Synchronisierung abgeschlossen"
)
self.log("Synchronisierung abgeschlossen")
# =========================================================
# Main
# =========================================================
if __name__ == "__main__":
root = tk.Tk()
app = ESXiNetBoxImporter(root)
root.mainloop()
+20
View File
@@ -0,0 +1,20 @@
from netbox.plugins import PluginConfig
class VMwareImporterConfig(PluginConfig):
name = "netbox_vmware_importer"
verbose_name = "VMware Importer"
description = "Synchronize VMware vSphere virtual machines into NetBox."
version = "0.1.0"
author = "Internal NetBox Team"
base_url = "vmware-importer"
min_version = "4.4.0"
def ready(self):
super().ready()
# Importing registers the scheduler system job with NetBox.
from . import jobs # noqa: F401
config = VMwareImporterConfig
+18
View File
@@ -0,0 +1,18 @@
from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet
class SyncStatusChoices(ChoiceSet):
STATUS_NEVER = "never"
STATUS_QUEUED = "queued"
STATUS_RUNNING = "running"
STATUS_SUCCESS = "success"
STATUS_FAILED = "failed"
CHOICES = [
(STATUS_NEVER, _("Never synced"), "gray"),
(STATUS_QUEUED, _("Queued"), "blue"),
(STATUS_RUNNING, _("Running"), "cyan"),
(STATUS_SUCCESS, _("Success"), "green"),
(STATUS_FAILED, _("Failed"), "red"),
]
+25
View File
@@ -0,0 +1,25 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from django.conf import settings
def _fernet():
key = hashlib.sha256(settings.SECRET_KEY.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_secret(value):
if value in (None, ""):
return ""
return _fernet().encrypt(value.encode("utf-8")).decode("ascii")
def decrypt_secret(value):
if not value:
return ""
try:
return _fernet().decrypt(value.encode("ascii")).decode("utf-8")
except InvalidToken as exc:
raise ValueError("Stored VMware password cannot be decrypted. Re-enter it on the endpoint.") from exc
+53
View File
@@ -0,0 +1,53 @@
from django.db.models import Q
import django_filters
from dcim.models import Site
from netbox.filtersets import NetBoxModelFilterSet
from tenancy.models import Tenant
from virtualization.models import Cluster
from .choices import SyncStatusChoices
from .models import VCenterEndpoint
class VCenterEndpointFilterSet(NetBoxModelFilterSet):
tenant_id = django_filters.ModelMultipleChoiceFilter(
field_name="tenant",
queryset=Tenant.objects.all(),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name="site",
queryset=Site.objects.all(),
)
cluster_id = django_filters.ModelMultipleChoiceFilter(
field_name="cluster",
queryset=Cluster.objects.all(),
)
last_status = django_filters.MultipleChoiceFilter(
choices=SyncStatusChoices,
)
class Meta:
model = VCenterEndpoint
fields = (
"id",
"name",
"slug",
"enabled",
"host",
"tenant_id",
"site_id",
"cluster_id",
"last_status",
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
| Q(slug__icontains=value)
| Q(host__icontains=value)
| Q(username__icontains=value)
| Q(comments__icontains=value)
)
+140
View File
@@ -0,0 +1,140 @@
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from dcim.models import Site
from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm
from tenancy.models import Tenant
from utilities.forms.fields import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
MultipleChoiceField,
SlugField,
)
from utilities.forms.rendering import FieldSet
from virtualization.models import Cluster
from .choices import SyncStatusChoices
from .models import VCenterEndpoint
class VCenterEndpointForm(NetBoxModelForm):
slug = SlugField()
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
)
password = forms.CharField(
label=_("Password"),
required=False,
widget=forms.PasswordInput(render_value=False),
help_text=_("Leave blank to keep the currently stored password."),
)
fieldsets = (
FieldSet("name", "slug", "enabled", "comments", name=_("Endpoint")),
FieldSet("host", "port", "username", "password", "validate_ssl", name=_("VMware connection")),
FieldSet("tenant", "site", "cluster", name=_("NetBox target")),
FieldSet(
"include_name_regex",
"exclude_name_regex",
"sync_powered_off",
"sync_interfaces",
"sync_ip_addresses",
"sync_primary_ips",
"update_existing",
name=_("Sync behavior"),
),
FieldSet("default_ipv4_prefix_length", "default_ipv6_prefix_length", "sync_interval_minutes", name=_("Automation")),
FieldSet("tags", name=_("Tags")),
)
class Meta:
model = VCenterEndpoint
fields = (
"name",
"slug",
"enabled",
"host",
"port",
"username",
"password",
"validate_ssl",
"tenant",
"site",
"cluster",
"include_name_regex",
"exclude_name_regex",
"sync_powered_off",
"sync_interfaces",
"sync_ip_addresses",
"sync_primary_ips",
"update_existing",
"default_ipv4_prefix_length",
"default_ipv6_prefix_length",
"sync_interval_minutes",
"comments",
"tags",
)
def clean(self):
cleaned_data = super().clean()
password = cleaned_data.get("password")
if not self.instance.pk and not password:
raise ValidationError({"password": _("A password is required for new endpoints.")})
return cleaned_data
def save(self, commit=True):
obj = super().save(commit=False)
password = self.cleaned_data.get("password")
if password:
obj.set_password(password)
if commit:
obj.save()
self.save_m2m()
return obj
class VCenterEndpointFilterForm(NetBoxModelFilterSetForm):
model = VCenterEndpoint
enabled = forms.NullBooleanField(
required=False,
label=_("Enabled"),
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_("Tenant"),
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
label=_("Site"),
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_("Cluster"),
)
last_status = MultipleChoiceField(
choices=SyncStatusChoices,
required=False,
label=_("Last status"),
)
fieldsets = (
FieldSet("q", "enabled", "tenant_id", "site_id", "cluster_id", "last_status", name=_("Endpoint")),
)
+90
View File
@@ -0,0 +1,90 @@
from core.exceptions import JobFailed
from django.utils import timezone
from netbox.jobs import JobRunner, system_job
from .choices import SyncStatusChoices
from .models import VCenterEndpoint
from .sync import VMwareImporter
class SyncVCenterEndpointJob(JobRunner):
class Meta:
name = "VMware VM synchronization"
def run(self, *args, **kwargs):
endpoint = self.job.object
if endpoint is None:
endpoint_pk = kwargs.get("endpoint_pk")
endpoint = VCenterEndpoint.objects.get(pk=endpoint_pk)
if not endpoint.enabled:
endpoint.mark_failure("Endpoint is disabled.")
raise JobFailed("Endpoint is disabled.")
self.logger.info("Starting VMware sync for %s (%s)", endpoint.name, endpoint.host)
endpoint.mark_running()
try:
result = VMwareImporter(endpoint, self.logger).sync()
except Exception as exc:
endpoint.mark_failure(exc)
raise
if result.errors:
endpoint.last_sync_at = timezone.now()
endpoint.last_status = SyncStatusChoices.STATUS_FAILED
endpoint.last_vm_count = result.seen
endpoint.last_created_count = result.created
endpoint.last_updated_count = result.updated
endpoint.last_error_count = result.errors
endpoint.last_status = SyncStatusChoices.STATUS_FAILED
endpoint.last_message = f"Finished with {result.errors} VM error(s). Check the job log."
endpoint.set_next_sync(endpoint.last_sync_at)
endpoint.save(
update_fields=(
"last_sync_at",
"last_status",
"last_vm_count",
"last_created_count",
"last_updated_count",
"last_error_count",
"last_message",
"next_sync_at",
"last_updated",
)
)
raise JobFailed(endpoint.last_message)
endpoint.mark_success(result)
self.logger.info(
"VMware sync finished for %s: %s VM(s), %s created, %s updated, %s skipped.",
endpoint.name,
result.synced,
result.created,
result.updated,
result.skipped,
)
@system_job(interval=5)
class ScheduleDueVCenterSyncsJob(JobRunner):
class Meta:
name = "Schedule due VMware VM synchronizations"
def run(self, *args, **kwargs):
now = timezone.now()
due_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
self.logger.info("Queued %s VMware sync job(s).", queued)
@@ -0,0 +1,143 @@
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("dcim", "0001_initial"),
("tenancy", "0001_initial"),
("virtualization", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="VCenterEndpoint",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("last_updated", models.DateTimeField(auto_now=True, null=True)),
("custom_field_data", models.JSONField(blank=True, default=dict)),
("name", models.CharField(max_length=100, unique=True)),
("slug", models.SlugField(max_length=100, unique=True)),
("enabled", models.BooleanField(default=True)),
("host", models.CharField(help_text="vCenter or ESXi hostname or IP address", max_length=255)),
(
"port",
models.PositiveIntegerField(
default=443,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(65535),
],
),
),
("username", models.CharField(max_length=255)),
("password_ciphertext", models.TextField(blank=True, editable=False)),
("validate_ssl", models.BooleanField(default=False, help_text="Validate the VMware endpoint certificate.")),
(
"include_name_regex",
models.CharField(
blank=True,
help_text="Only synchronize matching VM names. Leave empty to include all VMs.",
max_length=255,
),
),
("exclude_name_regex", models.CharField(blank=True, help_text="Skip matching VM names.", max_length=255)),
("sync_powered_off", models.BooleanField(default=True, verbose_name="Sync powered-off VMs")),
("sync_interfaces", models.BooleanField(default=True)),
("sync_ip_addresses", models.BooleanField(default=True)),
("sync_primary_ips", models.BooleanField(default=True)),
("update_existing", models.BooleanField(default=True, help_text="Update existing VMs matched by name and cluster.")),
(
"default_ipv4_prefix_length",
models.PositiveSmallIntegerField(
default=24,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(32),
],
),
),
(
"default_ipv6_prefix_length",
models.PositiveSmallIntegerField(
default=64,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(128),
],
),
),
(
"sync_interval_minutes",
models.PositiveIntegerField(
blank=True,
help_text="Optional automatic sync interval in minutes. Leave empty for manual sync only.",
null=True,
validators=[django.core.validators.MinValueValidator(5)],
),
),
("next_sync_at", models.DateTimeField(blank=True, editable=False, null=True)),
("last_sync_at", models.DateTimeField(blank=True, editable=False, null=True)),
("last_success_at", models.DateTimeField(blank=True, editable=False, null=True)),
(
"last_status",
models.CharField(
choices=[
("never", "Never synced"),
("queued", "Queued"),
("running", "Running"),
("success", "Success"),
("failed", "Failed"),
],
default="never",
editable=False,
max_length=30,
),
),
("last_message", models.CharField(blank=True, editable=False, max_length=500)),
("last_vm_count", models.PositiveIntegerField(default=0, editable=False)),
("last_created_count", models.PositiveIntegerField(default=0, editable=False)),
("last_updated_count", models.PositiveIntegerField(default=0, editable=False)),
("last_error_count", models.PositiveIntegerField(default=0, editable=False)),
("comments", models.TextField(blank=True)),
(
"cluster",
models.ForeignKey(
help_text="NetBox cluster into which VMware VMs will be synchronized.",
on_delete=django.db.models.deletion.PROTECT,
related_name="vmware_import_endpoints",
to="virtualization.cluster",
),
),
(
"site",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="vmware_import_endpoints",
to="dcim.site",
),
),
(
"tenant",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="vmware_import_endpoints",
to="tenancy.tenant",
),
),
],
options={
"verbose_name": "vCenter endpoint",
"verbose_name_plural": "vCenter endpoints",
"ordering": ("name",),
},
),
]
@@ -0,0 +1 @@
+268
View File
@@ -0,0 +1,268 @@
import re
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from netbox.models import NetBoxModel
from netbox.models.features import JobsMixin
from .choices import SyncStatusChoices
from .crypto import decrypt_secret, encrypt_secret
class VCenterEndpoint(JobsMixin, NetBoxModel):
name = models.CharField(
max_length=100,
unique=True,
)
slug = models.SlugField(
max_length=100,
unique=True,
)
enabled = models.BooleanField(
default=True,
)
host = models.CharField(
max_length=255,
help_text=_("vCenter or ESXi hostname or IP address"),
)
port = models.PositiveIntegerField(
default=443,
validators=[MinValueValidator(1), MaxValueValidator(65535)],
)
username = models.CharField(
max_length=255,
)
password_ciphertext = models.TextField(
blank=True,
editable=False,
)
validate_ssl = models.BooleanField(
default=False,
help_text=_("Validate the VMware endpoint certificate."),
)
tenant = models.ForeignKey(
to="tenancy.Tenant",
on_delete=models.PROTECT,
related_name="vmware_import_endpoints",
blank=True,
null=True,
)
site = models.ForeignKey(
to="dcim.Site",
on_delete=models.PROTECT,
related_name="vmware_import_endpoints",
blank=True,
null=True,
)
cluster = models.ForeignKey(
to="virtualization.Cluster",
on_delete=models.PROTECT,
related_name="vmware_import_endpoints",
help_text=_("NetBox cluster into which VMware VMs will be synchronized."),
)
include_name_regex = models.CharField(
max_length=255,
blank=True,
help_text=_("Only synchronize matching VM names. Leave empty to include all VMs."),
)
exclude_name_regex = models.CharField(
max_length=255,
blank=True,
help_text=_("Skip matching VM names."),
)
sync_powered_off = models.BooleanField(
default=True,
verbose_name=_("Sync powered-off VMs"),
)
sync_interfaces = models.BooleanField(
default=True,
)
sync_ip_addresses = models.BooleanField(
default=True,
)
sync_primary_ips = models.BooleanField(
default=True,
)
update_existing = models.BooleanField(
default=True,
help_text=_("Update existing VMs matched by name and cluster."),
)
default_ipv4_prefix_length = models.PositiveSmallIntegerField(
default=24,
validators=[MinValueValidator(1), MaxValueValidator(32)],
)
default_ipv6_prefix_length = models.PositiveSmallIntegerField(
default=64,
validators=[MinValueValidator(1), MaxValueValidator(128)],
)
sync_interval_minutes = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(5)],
help_text=_("Optional automatic sync interval in minutes. Leave empty for manual sync only."),
)
next_sync_at = models.DateTimeField(
blank=True,
null=True,
editable=False,
)
last_sync_at = models.DateTimeField(
blank=True,
null=True,
editable=False,
)
last_success_at = models.DateTimeField(
blank=True,
null=True,
editable=False,
)
last_status = models.CharField(
max_length=30,
choices=SyncStatusChoices,
default=SyncStatusChoices.STATUS_NEVER,
editable=False,
)
last_message = models.CharField(
max_length=500,
blank=True,
editable=False,
)
last_vm_count = models.PositiveIntegerField(
default=0,
editable=False,
)
last_created_count = models.PositiveIntegerField(
default=0,
editable=False,
)
last_updated_count = models.PositiveIntegerField(
default=0,
editable=False,
)
last_error_count = models.PositiveIntegerField(
default=0,
editable=False,
)
comments = models.TextField(
blank=True,
)
clone_fields = (
"enabled",
"host",
"port",
"username",
"validate_ssl",
"tenant",
"site",
"cluster",
"include_name_regex",
"exclude_name_regex",
"sync_powered_off",
"sync_interfaces",
"sync_ip_addresses",
"sync_primary_ips",
"update_existing",
"default_ipv4_prefix_length",
"default_ipv6_prefix_length",
"sync_interval_minutes",
)
class Meta:
ordering = ("name",)
verbose_name = _("vCenter endpoint")
verbose_name_plural = _("vCenter endpoints")
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("plugins:netbox_vmware_importer:vcenterendpoint", args=[self.pk])
@property
def password(self):
return decrypt_secret(self.password_ciphertext)
def set_password(self, value):
self.password_ciphertext = encrypt_secret(value)
def clean(self):
super().clean()
for field_name in ("include_name_regex", "exclude_name_regex"):
pattern = getattr(self, field_name)
if not pattern:
continue
try:
re.compile(pattern)
except re.error as exc:
raise ValidationError({field_name: _("Invalid regular expression: %(error)s") % {"error": exc}})
if self.site_id and self.cluster_id and getattr(self.cluster, "site_id", None):
if self.cluster.site_id != self.site_id:
raise ValidationError({"cluster": _("Selected cluster belongs to a different site.")})
def mark_queued(self):
self.last_status = SyncStatusChoices.STATUS_QUEUED
self.last_message = _("Sync job queued.")
self.save(update_fields=("last_status", "last_message", "last_updated"))
def mark_running(self):
self.last_sync_at = timezone.now()
self.last_status = SyncStatusChoices.STATUS_RUNNING
self.last_message = _("Sync job running.")
self.save(update_fields=("last_sync_at", "last_status", "last_message", "last_updated"))
def mark_success(self, result):
now = timezone.now()
self.last_sync_at = now
self.last_success_at = now
self.last_status = SyncStatusChoices.STATUS_SUCCESS
self.last_vm_count = result.seen
self.last_created_count = result.created
self.last_updated_count = result.updated
self.last_error_count = result.errors
self.last_message = _(
"Synchronized %(synced)s VM(s), skipped %(skipped)s VM(s)."
) % {"synced": result.synced, "skipped": result.skipped}
self.set_next_sync(now)
self.save(
update_fields=(
"last_sync_at",
"last_success_at",
"last_status",
"last_vm_count",
"last_created_count",
"last_updated_count",
"last_error_count",
"last_message",
"next_sync_at",
"last_updated",
)
)
def mark_failure(self, message):
now = timezone.now()
self.last_sync_at = now
self.last_status = SyncStatusChoices.STATUS_FAILED
self.last_message = str(message)[:500]
self.set_next_sync(now)
self.save(update_fields=("last_sync_at", "last_status", "last_message", "next_sync_at", "last_updated"))
def set_next_sync(self, now=None):
if self.enabled and self.sync_interval_minutes:
self.next_sync_at = (now or timezone.now()) + timedelta(minutes=self.sync_interval_minutes)
else:
self.next_sync_at = None
def save(self, *args, **kwargs):
if not self.enabled or not self.sync_interval_minutes:
self.next_sync_at = None
elif not self.next_sync_at:
self.set_next_sync()
return super().save(*args, **kwargs)
+26
View File
@@ -0,0 +1,26 @@
from netbox.choices import ButtonColorChoices
from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
endpoint_item = PluginMenuItem(
link="plugins:netbox_vmware_importer:vcenterendpoint_list",
link_text="vCenter Endpoints",
permissions=["netbox_vmware_importer.view_vcenterendpoint"],
buttons=(
PluginMenuButton(
"plugins:netbox_vmware_importer:vcenterendpoint_add",
"Add",
"mdi mdi-plus-thick",
ButtonColorChoices.GREEN,
permissions=["netbox_vmware_importer.add_vcenterendpoint"],
),
),
)
menu = PluginMenu(
label="VMware Import",
groups=(
("VMware", (endpoint_item,)),
),
icon_class="mdi mdi-cloud-sync",
)
+377
View File
@@ -0,0 +1,377 @@
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)
+53
View File
@@ -0,0 +1,53 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable
from netbox.tables.columns import BooleanColumn, ChoiceFieldColumn
from .models import VCenterEndpoint
class VCenterEndpointTable(NetBoxTable):
name = tables.Column(
linkify=True,
)
enabled = BooleanColumn()
tenant = tables.Column(
linkify=True,
)
site = tables.Column(
linkify=True,
)
cluster = tables.Column(
linkify=True,
)
last_status = ChoiceFieldColumn(
verbose_name=_("Last status"),
)
class Meta(NetBoxTable.Meta):
model = VCenterEndpoint
fields = (
"pk",
"id",
"name",
"enabled",
"host",
"tenant",
"site",
"cluster",
"sync_interval_minutes",
"next_sync_at",
"last_status",
"last_sync_at",
"last_success_at",
)
default_columns = (
"pk",
"name",
"enabled",
"host",
"tenant",
"cluster",
"last_status",
"last_sync_at",
)
@@ -0,0 +1,136 @@
{% extends 'generic/object.html' %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">vCenter</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Host</th>
<td>{{ object.host }}:{{ object.port }}</td>
</tr>
<tr>
<th scope="row">Username</th>
<td>{{ object.username }}</td>
</tr>
<tr>
<th scope="row">Enabled</th>
<td>{{ object.enabled|yesno:"Yes,No" }}</td>
</tr>
<tr>
<th scope="row">Validate SSL</th>
<td>{{ object.validate_ssl|yesno:"Yes,No" }}</td>
</tr>
</table>
</div>
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">NetBox Target</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">Tenant</th>
<td>{{ object.tenant|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Site</th>
<td>{{ object.site|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Cluster</th>
<td>{{ object.cluster }}</td>
</tr>
<tr>
<th scope="row">Sync interval</th>
<td>{{ object.sync_interval_minutes|default:"Manual only" }}</td>
</tr>
<tr>
<th scope="row">Next sync</th>
<td>{{ object.next_sync_at|default:"-" }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Sync</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">Last status</th>
<td>{{ object.get_last_status_display }}</td>
</tr>
<tr>
<th scope="row">Last sync</th>
<td>{{ object.last_sync_at|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Last success</th>
<td>{{ object.last_success_at|default:"-" }}</td>
</tr>
<tr>
<th scope="row">VMs</th>
<td>{{ object.last_vm_count }} total, {{ object.last_created_count }} created, {{ object.last_updated_count }} updated, {{ object.last_error_count }} errors</td>
</tr>
<tr>
<th scope="row">Message</th>
<td>{{ object.last_message|default:"-" }}</td>
</tr>
</table>
{% if perms.netbox_vmware_importer.change_vcenterendpoint %}
<div class="card-footer">
<form method="post" action="{% url 'plugins:netbox_vmware_importer:vcenterendpoint_sync' pk=object.pk %}">
{% csrf_token %}
<button type="submit" class="btn btn-primary">
<i class="mdi mdi-sync"></i> Sync jetzt starten
</button>
</form>
</div>
{% endif %}
</div>
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Filters</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">Include regex</th>
<td>{{ object.include_name_regex|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Exclude regex</th>
<td>{{ object.exclude_name_regex|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Powered off VMs</th>
<td>{{ object.sync_powered_off|yesno:"Synced,Skipped" }}</td>
</tr>
<tr>
<th scope="row">Interfaces/IPs</th>
<td>{{ object.sync_interfaces|yesno:"Interfaces on,Interfaces off" }} / {{ object.sync_ip_addresses|yesno:"IPs on,IPs off" }}</td>
</tr>
</table>
</div>
</div>
</div>
{% if object.comments %}
<div class="row mb-3">
<div class="col">
<div class="card">
<h5 class="card-header">Comments</h5>
<div class="card-body rendered-markdown">
{{ object.comments|linebreaksbr }}
</div>
</div>
</div>
</div>
{% endif %}
{% endblock content %}
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from . import views
urlpatterns = (
path("vcenters/", views.VCenterEndpointListView.as_view(), name="vcenterendpoint_list"),
path("vcenters/add/", views.VCenterEndpointEditView.as_view(), name="vcenterendpoint_add"),
path("vcenters/<int:pk>/", views.VCenterEndpointView.as_view(), name="vcenterendpoint"),
path("vcenters/<int:pk>/edit/", views.VCenterEndpointEditView.as_view(), name="vcenterendpoint_edit"),
path("vcenters/<int:pk>/delete/", views.VCenterEndpointDeleteView.as_view(), name="vcenterendpoint_delete"),
path("vcenters/<int:pk>/sync/", views.VCenterEndpointSyncView.as_view(), name="vcenterendpoint_sync"),
)
+51
View File
@@ -0,0 +1,51 @@
from django.contrib import messages
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect
from django.utils.translation import gettext_lazy as _
from django.views import View
from netbox.views import generic
from .filtersets import VCenterEndpointFilterSet
from .forms import VCenterEndpointFilterForm, VCenterEndpointForm
from .jobs import SyncVCenterEndpointJob
from .models import VCenterEndpoint
from .tables import VCenterEndpointTable
class VCenterEndpointListView(generic.ObjectListView):
queryset = VCenterEndpoint.objects.all()
table = VCenterEndpointTable
filterset = VCenterEndpointFilterSet
filterset_form = VCenterEndpointFilterForm
class VCenterEndpointView(generic.ObjectView):
queryset = VCenterEndpoint.objects.all()
def get_extra_context(self, request, instance):
return {
"latest_jobs": instance.get_latest_jobs()[:10],
}
class VCenterEndpointEditView(generic.ObjectEditView):
queryset = VCenterEndpoint.objects.all()
form = VCenterEndpointForm
class VCenterEndpointDeleteView(generic.ObjectDeleteView):
queryset = VCenterEndpoint.objects.all()
class VCenterEndpointSyncView(View):
def post(self, request, pk):
endpoint = get_object_or_404(VCenterEndpoint, pk=pk)
if not request.user.has_perm("netbox_vmware_importer.change_vcenterendpoint"):
return HttpResponseForbidden()
SyncVCenterEndpointJob.enqueue(instance=endpoint)
endpoint.mark_queued()
messages.success(request, _("VMware sync job queued for %(endpoint)s.") % {"endpoint": endpoint})
return redirect(endpoint)
+32
View File
@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "netbox-vmware-importer"
version = "0.1.0"
description = "NetBox plugin to synchronize VMware vSphere virtual machines into NetBox."
readme = "README.md"
requires-python = ">=3.12"
authors = [
{name = "Internal NetBox Team"}
]
classifiers = [
"Development Status :: 3 - Alpha",
"Framework :: Django",
"Intended Audience :: System Administrators",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"netbox>=4.4,<4.6",
"pyvmomi>=8.0.3",
"cryptography>=42.0",
]
[tool.setuptools.packages.find]
include = ["netbox_vmware_importer*"]
[tool.setuptools.package-data]
netbox_vmware_importer = ["templates/**/*.html"]