Remove Proxmox sync support

This commit is contained in:
2026-07-20 14:24:28 +02:00
parent 7d8819c374
commit 6ec4093c97
15 changed files with 192 additions and 752 deletions
+1
View File
@@ -1,2 +1,3 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.egg-info/
+1
View File
@@ -18,3 +18,4 @@
| 0.2.4 | 4.4.0 | 4.6.x | | 0.2.4 | 4.4.0 | 4.6.x |
| 0.2.5 | 4.4.0 | 4.6.x | | 0.2.5 | 4.4.0 | 4.6.x |
| 0.2.6 | 4.4.0 | 4.6.x | | 0.2.6 | 4.4.0 | 4.6.x |
| 0.3.0 | 4.4.0 | 4.6.x |
+14 -27
View File
@@ -1,11 +1,10 @@
# NetBox VMware & Proxmox Importer # NetBox VMware Importer
NetBox plugin zum Synchronisieren von VMware vSphere/vCenter und Proxmox VE VMs nach NetBox. NetBox plugin zum Synchronisieren von VMware vSphere/vCenter VMs nach NetBox.
## Funktionen ## Funktionen
- vCenter/ESXi-Ziele direkt in der NetBox-Weboberflaeche pflegen - vCenter/ESXi-Ziele direkt in der NetBox-Weboberflaeche pflegen
- Proxmox VE-Ziele direkt in der NetBox-Weboberflaeche pflegen
- Pro Ziel Tenant, Site und NetBox-Cluster hinterlegen - Pro Ziel Tenant, Site und NetBox-Cluster hinterlegen
- Manuelle Synchronisation per Button - Manuelle Synchronisation per Button
- Optionale automatische Synchronisation ueber ein Minutenintervall - Optionale automatische Synchronisation ueber ein Minutenintervall
@@ -15,7 +14,7 @@ NetBox plugin zum Synchronisieren von VMware vSphere/vCenter und Proxmox VE VMs
- 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`
Das Plugin loescht bewusst keine verwaisten VMs, Interfaces oder IP-Adressen. Damit bleibt der erste Rollout konservativ und vermeidet Datenverlust, wenn Hypervisor-Gastdaten unvollstaendig sind. Das Plugin loescht bewusst keine verwaisten VMs, Interfaces oder IP-Adressen. Damit bleibt der erste Rollout konservativ und vermeidet Datenverlust, wenn VMware-Gastdaten unvollstaendig sind.
## Installation ## Installation
@@ -25,7 +24,7 @@ Im Python-Environment der NetBox-Installation:
pip install git+https://git.mrblake.cc/MrBlake/NetBox-VM-Import.git pip install git+https://git.mrblake.cc/MrBlake/NetBox-VM-Import.git
``` ```
NetBox selbst ist keine `pip`-Dependency des Plugins. Das Plugin wird im vorhandenen NetBox-Virtualenv installiert und bringt nur externe Bibliotheken wie `pyvmomi`, `cryptography`, `requests` und `proxmox-sdk` mit. NetBox selbst ist keine `pip`-Dependency des Plugins. Das Plugin wird im vorhandenen NetBox-Virtualenv installiert und bringt nur externe Bibliotheken wie `pyvmomi` und `cryptography` mit.
In `configuration.py` aktivieren: In `configuration.py` aktivieren:
@@ -44,36 +43,24 @@ sudo systemctl restart netbox netbox-rq
## Nutzung ## Nutzung
1. In NetBox unter `VM Import > Endpoints` ein Ziel anlegen. 1. In NetBox unter `VMware Import > Endpoints` ein Ziel anlegen.
2. Provider auswaehlen. 2. vCenter/ESXi Host, Port, Benutzername und Passwort eintragen.
3. VMware: vCenter Host, Port, Benutzername und Passwort eintragen. 3. Tenant, Site und Cluster fuer den Kunden auswaehlen.
4. Proxmox: Proxmox Host, Port `8006`, Benutzername inklusive Realm und Passwort oder API-Token eintragen, z. B. `root@pam`. 4. Optional Regex-Filter und Sync-Intervall setzen.
5. Tenant, Site und Cluster fuer den Kunden auswaehlen. 5. Auf der Detailseite `Sync jetzt starten` ausfuehren.
6. Optional Regex-Filter und Sync-Intervall setzen.
7. Auf der Detailseite `Sync jetzt starten` ausfuehren.
Bei selbstsignierten Proxmox-Zertifikaten `Validate SSL` deaktiviert lassen. Wenn Proxmox langsam antwortet, kann `API timeout` am Endpoint erhoeht werden. Wenn `Sync interval minutes` gesetzt ist, prueft ein Systemjob alle fuenf Minuten, welche VMware-Endpoints faellig sind, und stellt die eigentlichen Sync-Jobs in die Queue.
Der Proxmox-Client nutzt fuer API-Token bevorzugt `proxmox-sdk` wie `netbox-proxbox`/`proxbox-api`. Passwort-Logins laufen ueber den eigenen `requests`-Client, weil `proxmox-sdk==0.0.12` die Ticket-URL fuer Passwort-Authentifizierung ohne `/api2/json` aufbaut. Proxy-Umgebungsvariablen des NetBox-Dienstes werden im `requests`-Client ignoriert, damit interne Proxmox-Adressen nicht versehentlich ueber einen HTTPS-Proxy laufen. ## Legacy-Proxmox-Endpoints
Im `requests`-Fallback ist der unauthentifizierte Proxmox-`version`-Probe nur diagnostisch. Wenn er haengt, versucht das Plugin den eigentlichen Login trotzdem. Im bevorzugten SDK-Pfad wird `version` als erste SDK-Anfrage genutzt und authentifiziert dabei automatisch. Proxmox-Unterstuetzung wurde entfernt. Alte Proxmox-Endpoints aus frueheren Plugin-Versionen bleiben in der Liste und Detailansicht sichtbar, damit sie geloescht werden koennen. Sie koennen nicht mehr bearbeitet, neu angelegt oder synchronisiert werden.
Proxmox-API-Verbindungen nutzen TLS 1.2-Kompatibilitaetsmodus, weil einige Firewalls oder TLS-Inspektionspfade moderne TLS-1.3-ClientHellos blockieren.
Bei Proxmox koennen `Connect timeout`, `API timeout`, `GET retries` und `Retry backoff` pro Endpoint gesetzt werden. Das entspricht dem Ansatz von Proxbox, Verbindungs- und Retry-Parameter pro Proxmox-Ziel zu speichern.
Wenn Passwort-Login auf `access/ticket` haengt, zuerst den Realm im Benutzernamen pruefen (`root@pam`, `user@pve`, `user@ldaprealm`). Fuer produktive Imports ist ein Proxmox API-Token meistens stabiler als Passwort-Login.
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 Endpoint-Passwort bzw. Token-Secret wird verschluesselt in der Plugin-Tabelle gespeichert. Der Schluessel wird aus `SECRET_KEY` abgeleitet. Wenn `SECRET_KEY` rotiert wird, muessen die Secrets in den Endpoints erneut gesetzt werden. Das Endpoint-Passwort wird verschluesselt in der Plugin-Tabelle gespeichert. Der Schluessel wird aus `SECRET_KEY` abgeleitet. Wenn `SECRET_KEY` rotiert wird, muessen die Secrets in den Endpoints erneut gesetzt werden.
Fuer sehr strenge Umgebungen ist ein externer Secret-Store wie Vault als naechster sinnvoller Ausbaupunkt vorgesehen. Fuer sehr strenge Umgebungen ist ein externer Secret-Store wie Vault als naechster sinnvoller Ausbaupunkt vorgesehen.
## Datenqualitaet ## Datenqualitaet
IP-Adressen kommen bei VMware aus den VMware Guest Tools und bei Proxmox-QEMU aus dem QEMU Guest Agent. Ohne laufende bzw. aktuelle Guest Tools koennen Interfaces und IPs fehlen oder veraltet sein. IP-Adressen kommen aus den VMware Guest Tools. Ohne laufende bzw. aktuelle Guest Tools koennen Interfaces und IPs fehlen oder veraltet sein.
+3 -3
View File
@@ -3,9 +3,9 @@ from netbox.plugins import PluginConfig
class VMwareImporterConfig(PluginConfig): class VMwareImporterConfig(PluginConfig):
name = "netbox_vmware_importer" name = "netbox_vmware_importer"
verbose_name = "Virtualization Importer" verbose_name = "VMware Importer"
description = "Synchronize VMware vSphere and Proxmox VE virtual machines into NetBox." description = "Synchronize VMware vSphere virtual machines into NetBox."
version = "0.2.6" version = "0.3.0"
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"
+2 -2
View File
@@ -24,7 +24,7 @@ class EndpointProviderChoices(ChoiceSet):
CHOICES = [ CHOICES = [
(PROVIDER_VMWARE, _("VMware vSphere"), "blue"), (PROVIDER_VMWARE, _("VMware vSphere"), "blue"),
(PROVIDER_PROXMOX, _("Proxmox VE"), "orange"), (PROVIDER_PROXMOX, _("Proxmox VE (legacy)"), "gray"),
] ]
@@ -34,5 +34,5 @@ class EndpointAuthMethodChoices(ChoiceSet):
CHOICES = [ CHOICES = [
(METHOD_PASSWORD, _("Username/password"), "blue"), (METHOD_PASSWORD, _("Username/password"), "blue"),
(METHOD_API_TOKEN, _("API token"), "green"), (METHOD_API_TOKEN, _("API token (legacy)"), "gray"),
] ]
+5 -25
View File
@@ -29,16 +29,8 @@ class VCenterEndpointForm(NetBoxModelForm):
cluster = DynamicModelChoiceField( cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
) )
provider = forms.ChoiceField(
choices=EndpointProviderChoices,
required=True,
)
auth_method = forms.ChoiceField(
choices=EndpointAuthMethodChoices,
required=True,
)
password = forms.CharField( password = forms.CharField(
label=_("Password / token secret"), label=_("Password"),
required=False, required=False,
widget=forms.PasswordInput(render_value=False), widget=forms.PasswordInput(render_value=False),
help_text=_("Leave blank to keep the currently stored password."), help_text=_("Leave blank to keep the currently stored password."),
@@ -47,18 +39,11 @@ class VCenterEndpointForm(NetBoxModelForm):
fieldsets = ( fieldsets = (
FieldSet("name", "slug", "enabled", "comments", name=_("Endpoint")), FieldSet("name", "slug", "enabled", "comments", name=_("Endpoint")),
FieldSet( FieldSet(
"provider",
"host", "host",
"port", "port",
"username", "username",
"auth_method",
"token_name",
"password", "password",
"validate_ssl", "validate_ssl",
"request_timeout_seconds",
"connect_timeout_seconds",
"max_retries",
"retry_backoff_seconds",
name=_("Connection"), name=_("Connection"),
), ),
FieldSet("tenant", "site", "cluster", name=_("NetBox target")), FieldSet("tenant", "site", "cluster", name=_("NetBox target")),
@@ -69,7 +54,6 @@ class VCenterEndpointForm(NetBoxModelForm):
"sync_interfaces", "sync_interfaces",
"sync_ip_addresses", "sync_ip_addresses",
"sync_primary_ips", "sync_primary_ips",
"sync_lxc_containers",
"update_existing", "update_existing",
name=_("Sync behavior"), name=_("Sync behavior"),
), ),
@@ -83,18 +67,11 @@ class VCenterEndpointForm(NetBoxModelForm):
"name", "name",
"slug", "slug",
"enabled", "enabled",
"provider",
"host", "host",
"port", "port",
"username", "username",
"auth_method",
"token_name",
"password", "password",
"validate_ssl", "validate_ssl",
"request_timeout_seconds",
"connect_timeout_seconds",
"max_retries",
"retry_backoff_seconds",
"tenant", "tenant",
"site", "site",
"cluster", "cluster",
@@ -104,7 +81,6 @@ class VCenterEndpointForm(NetBoxModelForm):
"sync_interfaces", "sync_interfaces",
"sync_ip_addresses", "sync_ip_addresses",
"sync_primary_ips", "sync_primary_ips",
"sync_lxc_containers",
"update_existing", "update_existing",
"default_ipv4_prefix_length", "default_ipv4_prefix_length",
"default_ipv6_prefix_length", "default_ipv6_prefix_length",
@@ -126,6 +102,10 @@ class VCenterEndpointForm(NetBoxModelForm):
obj = super().save(commit=False) obj = super().save(commit=False)
password = self.cleaned_data.get("password") password = self.cleaned_data.get("password")
obj.provider = EndpointProviderChoices.PROVIDER_VMWARE
obj.auth_method = EndpointAuthMethodChoices.METHOD_PASSWORD
obj.token_name = ""
if password: if password:
obj.set_password(password) obj.set_password(password)
+19 -2
View File
@@ -3,7 +3,7 @@ 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
from .choices import SyncStatusChoices from .choices import EndpointProviderChoices, SyncStatusChoices
from .models import VCenterEndpoint from .models import VCenterEndpoint
from .sync import VMwareImporter from .sync import VMwareImporter
@@ -16,6 +16,10 @@ def clear_endpoint_sync_schedule(endpoint):
def schedule_endpoint_sync(endpoint): def schedule_endpoint_sync(endpoint):
if endpoint.provider != EndpointProviderChoices.PROVIDER_VMWARE:
clear_endpoint_sync_schedule(endpoint)
return None
if not endpoint.enabled or not endpoint.sync_interval_minutes: if not endpoint.enabled or not endpoint.sync_interval_minutes:
clear_endpoint_sync_schedule(endpoint) clear_endpoint_sync_schedule(endpoint)
return None return None
@@ -45,6 +49,12 @@ class SyncVCenterEndpointJob(JobRunner):
endpoint.mark_failure("Endpoint is disabled.") endpoint.mark_failure("Endpoint is disabled.")
raise JobFailed("Endpoint is disabled.") raise JobFailed("Endpoint is disabled.")
if endpoint.provider != EndpointProviderChoices.PROVIDER_VMWARE:
message = "Legacy Proxmox endpoints are no longer supported. Delete this endpoint."
clear_endpoint_sync_schedule(endpoint)
endpoint.mark_failure(message)
raise JobFailed(message)
self.logger.info("Starting VM sync for %s (%s)", endpoint.name, endpoint.host) self.logger.info("Starting VM sync for %s (%s)", endpoint.name, endpoint.host)
endpoint.mark_running() endpoint.mark_running()
@@ -107,8 +117,15 @@ class ScheduleDueVCenterSyncsJob(JobRunner):
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
now = timezone.now() now = timezone.now()
legacy_endpoints = VCenterEndpoint.objects.exclude(provider=EndpointProviderChoices.PROVIDER_VMWARE)
cleared = 0
for endpoint in legacy_endpoints:
deleted_count, _ = clear_endpoint_sync_schedule(endpoint)
cleared += deleted_count
enabled_endpoints = VCenterEndpoint.objects.filter( enabled_endpoints = VCenterEndpoint.objects.filter(
enabled=True, enabled=True,
provider=EndpointProviderChoices.PROVIDER_VMWARE,
sync_interval_minutes__isnull=False, sync_interval_minutes__isnull=False,
) )
@@ -120,4 +137,4 @@ class ScheduleDueVCenterSyncsJob(JobRunner):
if schedule_endpoint_sync(endpoint): if schedule_endpoint_sync(endpoint):
scheduled += 1 scheduled += 1
self.logger.info("Ensured %s endpoint sync schedule(s).", scheduled) self.logger.info("Ensured %s VMware endpoint sync schedule(s); cleared %s legacy schedule(s).", scheduled, cleared)
@@ -0,0 +1,106 @@
import decimal
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_vmware_importer", "0005_add_proxmox_connection_tuning"),
]
operations = [
migrations.AlterModelOptions(
name="vcenterendpoint",
options={
"ordering": ("name",),
"verbose_name": "VMware endpoint",
"verbose_name_plural": "VMware endpoints",
},
),
migrations.AlterField(
model_name="vcenterendpoint",
name="provider",
field=models.CharField(
choices=[
("vmware", "VMware vSphere"),
("proxmox", "Proxmox VE (legacy)"),
],
default="vmware",
max_length=30,
),
),
migrations.AlterField(
model_name="vcenterendpoint",
name="auth_method",
field=models.CharField(
choices=[
("password", "Username/password"),
("api_token", "API token (legacy)"),
],
default="password",
max_length=30,
),
),
migrations.AlterField(
model_name="vcenterendpoint",
name="host",
field=models.CharField(help_text="vCenter or ESXi hostname or IP address", max_length=255),
),
migrations.AlterField(
model_name="vcenterendpoint",
name="validate_ssl",
field=models.BooleanField(default=False, help_text="Validate the VMware endpoint certificate."),
),
migrations.AlterField(
model_name="vcenterendpoint",
name="cluster",
field=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",
),
),
migrations.AlterField(
model_name="vcenterendpoint",
name="token_name",
field=models.CharField(
blank=True,
help_text="Legacy Proxmox API token ID. Kept only for deleting old endpoints.",
max_length=100,
),
),
migrations.AlterField(
model_name="vcenterendpoint",
name="max_retries",
field=models.PositiveSmallIntegerField(
default=0,
help_text="Legacy connection retry setting. Kept only for compatibility with older plugin versions.",
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(10),
],
),
),
migrations.AlterField(
model_name="vcenterendpoint",
name="retry_backoff_seconds",
field=models.DecimalField(
decimal_places=2,
default=decimal.Decimal("0.50"),
help_text="Exponential retry backoff base delay in seconds.",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(decimal.Decimal("0.00")),
django.core.validators.MaxValueValidator(decimal.Decimal("60.00")),
],
),
),
migrations.AlterField(
model_name="vcenterendpoint",
name="sync_lxc_containers",
field=models.BooleanField(default=True, verbose_name="Sync legacy Proxmox LXC containers"),
),
]
+16 -26
View File
@@ -34,7 +34,7 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
) )
host = models.CharField( host = models.CharField(
max_length=255, max_length=255,
help_text=_("vCenter, ESXi, or Proxmox VE hostname or IP address"), help_text=_("vCenter or ESXi hostname or IP address"),
) )
port = models.PositiveIntegerField( port = models.PositiveIntegerField(
default=443, default=443,
@@ -51,7 +51,7 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
token_name = models.CharField( token_name = models.CharField(
max_length=100, max_length=100,
blank=True, blank=True,
help_text=_("Proxmox API token ID, e.g. netbox-import. The token secret is stored in the password field."), help_text=_("Legacy Proxmox API token ID. Kept only for deleting old endpoints."),
) )
password_ciphertext = models.TextField( password_ciphertext = models.TextField(
blank=True, blank=True,
@@ -59,7 +59,7 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
) )
validate_ssl = models.BooleanField( validate_ssl = models.BooleanField(
default=False, default=False,
help_text=_("Validate the endpoint TLS certificate."), help_text=_("Validate the VMware endpoint certificate."),
) )
request_timeout_seconds = models.PositiveIntegerField( request_timeout_seconds = models.PositiveIntegerField(
default=120, default=120,
@@ -74,7 +74,7 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
max_retries = models.PositiveSmallIntegerField( max_retries = models.PositiveSmallIntegerField(
default=0, default=0,
validators=[MinValueValidator(0), MaxValueValidator(10)], validators=[MinValueValidator(0), MaxValueValidator(10)],
help_text=_("Retries for safe Proxmox GET requests when the endpoint returns transient errors."), help_text=_("Legacy connection retry setting. Kept only for compatibility with older plugin versions."),
) )
retry_backoff_seconds = models.DecimalField( retry_backoff_seconds = models.DecimalField(
max_digits=5, max_digits=5,
@@ -101,7 +101,7 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
to="virtualization.Cluster", to="virtualization.Cluster",
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="vmware_import_endpoints", related_name="vmware_import_endpoints",
help_text=_("NetBox cluster into which hypervisor VMs will be synchronized."), help_text=_("NetBox cluster into which VMware VMs will be synchronized."),
) )
include_name_regex = models.CharField( include_name_regex = models.CharField(
max_length=255, max_length=255,
@@ -128,7 +128,7 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
) )
sync_lxc_containers = models.BooleanField( sync_lxc_containers = models.BooleanField(
default=True, default=True,
verbose_name=_("Sync Proxmox LXC containers"), verbose_name=_("Sync legacy Proxmox LXC containers"),
) )
update_existing = models.BooleanField( update_existing = models.BooleanField(
default=True, default=True,
@@ -196,17 +196,10 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
clone_fields = ( clone_fields = (
"enabled", "enabled",
"provider",
"host", "host",
"port", "port",
"username", "username",
"auth_method",
"token_name",
"validate_ssl", "validate_ssl",
"request_timeout_seconds",
"connect_timeout_seconds",
"max_retries",
"retry_backoff_seconds",
"tenant", "tenant",
"site", "site",
"cluster", "cluster",
@@ -216,7 +209,6 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
"sync_interfaces", "sync_interfaces",
"sync_ip_addresses", "sync_ip_addresses",
"sync_primary_ips", "sync_primary_ips",
"sync_lxc_containers",
"update_existing", "update_existing",
"default_ipv4_prefix_length", "default_ipv4_prefix_length",
"default_ipv6_prefix_length", "default_ipv6_prefix_length",
@@ -225,8 +217,8 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
class Meta: class Meta:
ordering = ("name",) ordering = ("name",)
verbose_name = _("virtualization endpoint") verbose_name = _("VMware endpoint")
verbose_name_plural = _("virtualization endpoints") verbose_name_plural = _("VMware endpoints")
def __str__(self): def __str__(self):
return self.name return self.name
@@ -257,15 +249,13 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
if self.cluster.site_id != self.site_id: if self.cluster.site_id != self.site_id:
raise ValidationError({"cluster": _("Selected cluster belongs to a different site.")}) raise ValidationError({"cluster": _("Selected cluster belongs to a different site.")})
if self.provider == EndpointProviderChoices.PROVIDER_VMWARE: if self.provider != EndpointProviderChoices.PROVIDER_VMWARE:
if self.auth_method != EndpointAuthMethodChoices.METHOD_PASSWORD: raise ValidationError(
raise ValidationError({"auth_method": _("VMware endpoints currently support username/password authentication only.")}) {"provider": _("Proxmox endpoints are legacy records and can only be deleted.")}
)
if self.provider == EndpointProviderChoices.PROVIDER_PROXMOX: if self.auth_method != EndpointAuthMethodChoices.METHOD_PASSWORD:
if "@" not in self.username: raise ValidationError({"auth_method": _("VMware endpoints support username/password authentication only.")})
raise ValidationError({"username": _("Proxmox usernames must include a realm, e.g. root@pam or user@pve.")})
if self.auth_method == EndpointAuthMethodChoices.METHOD_API_TOKEN and not self.token_name:
raise ValidationError({"token_name": _("A token name is required for Proxmox API token authentication.")})
def mark_queued(self): def mark_queued(self):
self.last_status = SyncStatusChoices.STATUS_QUEUED self.last_status = SyncStatusChoices.STATUS_QUEUED
@@ -315,13 +305,13 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
self.save(update_fields=("last_sync_at", "last_status", "last_message", "next_sync_at", "last_updated")) self.save(update_fields=("last_sync_at", "last_status", "last_message", "next_sync_at", "last_updated"))
def set_next_sync(self, now=None): def set_next_sync(self, now=None):
if self.enabled and self.sync_interval_minutes: if self.provider == EndpointProviderChoices.PROVIDER_VMWARE and self.enabled and self.sync_interval_minutes:
self.next_sync_at = (now or timezone.now()) + timedelta(minutes=self.sync_interval_minutes) self.next_sync_at = (now or timezone.now()) + timedelta(minutes=self.sync_interval_minutes)
else: else:
self.next_sync_at = None self.next_sync_at = None
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.enabled or not self.sync_interval_minutes: if self.provider != EndpointProviderChoices.PROVIDER_VMWARE or not self.enabled or not self.sync_interval_minutes:
self.next_sync_at = None self.next_sync_at = None
elif not self.next_sync_at: elif not self.next_sync_at:
self.set_next_sync() self.set_next_sync()
+2 -2
View File
@@ -18,9 +18,9 @@ endpoint_item = PluginMenuItem(
) )
menu = PluginMenu( menu = PluginMenu(
label="VM Import", label="VMware Import",
groups=( groups=(
("Providers", (endpoint_item,)), ("VMware", (endpoint_item,)),
), ),
icon_class="mdi mdi-cloud-sync", icon_class="mdi mdi-cloud-sync",
) )
+1 -1
View File
@@ -6,7 +6,7 @@ from .jobs import clear_endpoint_sync_schedule, schedule_endpoint_sync
from .models import VCenterEndpoint from .models import VCenterEndpoint
AUTOMATIC_SYNC_FIELDS = {"enabled", "sync_interval_minutes"} AUTOMATIC_SYNC_FIELDS = {"enabled", "provider", "sync_interval_minutes"}
@receiver(post_save, sender=VCenterEndpoint) @receiver(post_save, sender=VCenterEndpoint)
+6 -637
View File
@@ -1,9 +1,7 @@
import ipaddress import ipaddress
import re import re
import ssl import ssl
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from urllib.parse import urlsplit
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
@@ -12,29 +10,7 @@ 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
from .choices import EndpointAuthMethodChoices, EndpointProviderChoices from .choices import EndpointProviderChoices
try:
import requests
from requests.adapters import HTTPAdapter
from urllib3.exceptions import InsecureRequestWarning
except ImportError: # pragma: no cover - handled at runtime inside NetBox
HTTPAdapter = None
requests = None
InsecureRequestWarning = None
try:
from proxmox_sdk import ProxmoxSDK
from proxmox_sdk.sdk.exceptions import (
ProxmoxConnectionError as SDKConnectionError,
ProxmoxTimeoutError as SDKTimeoutError,
ResourceException as SDKResourceException,
)
except ImportError: # pragma: no cover - optional compatibility fallback
ProxmoxSDK = None
SDKConnectionError = None
SDKTimeoutError = None
SDKResourceException = None
try: try:
from pyVim.connect import Disconnect, SmartConnect from pyVim.connect import Disconnect, SmartConnect
@@ -96,34 +72,6 @@ class VMwareConnectionError(RuntimeError):
pass pass
class ProxmoxConnectionError(RuntimeError):
pass
class ProxmoxTLSCompatibilityAdapter(HTTPAdapter):
def __init__(self, validate_ssl=True, *args, **kwargs):
self.validate_ssl = validate_ssl
super().__init__(*args, **kwargs)
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
pool_kwargs["ssl_context"] = self._build_ssl_context()
return super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
def proxy_manager_for(self, *args, **kwargs):
kwargs["ssl_context"] = self._build_ssl_context()
return super().proxy_manager_for(*args, **kwargs)
def _build_ssl_context(self):
context = ssl.create_default_context()
if hasattr(ssl, "TLSVersion"):
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.maximum_version = ssl.TLSVersion.TLSv1_2
if not self.validate_ssl:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
class VMwareClient: class VMwareClient:
def __init__(self, endpoint): def __init__(self, endpoint):
self.endpoint = endpoint self.endpoint = endpoint
@@ -264,605 +212,26 @@ class VMwareClient:
return interfaces return interfaces
class ProxmoxClient:
DISK_KEY_PATTERN = re.compile(r"^(ide|sata|scsi|virtio)\d+$")
LXC_DISK_KEY_PATTERN = re.compile(r"^(rootfs|mp\d+)$")
NET_KEY_PATTERN = re.compile(r"^net\d+$")
FULL_TOKEN_PATTERN = re.compile(r"^(?:PVEAPIToken=)?(?P<user>[^!]+)!(?P<name>[^=]+)=(?P<value>.+)$")
RETRY_STATUSES = {502, 503, 504}
def __init__(self, endpoint):
self.endpoint = endpoint
self.host, self.port = self._parse_endpoint_address(endpoint.host, endpoint.port)
self.url_host = self._format_host_for_url(self.host)
self.session = None
self.sdk = None
self.uses_sdk = False
self.base_url = f"https://{self.url_host}:{self.port}/api2/json"
self.connect_timeout = endpoint.connect_timeout_seconds or 10
self.read_timeout = endpoint.request_timeout_seconds or 120
self.timeout = (self.connect_timeout, self.read_timeout)
self.max_retries = endpoint.max_retries or 0
self.retry_backoff = float(endpoint.retry_backoff_seconds or 0.5)
self.api_probe_succeeded = False
self.api_probe_error = ""
def __enter__(self):
if "@" not in self.endpoint.username:
raise ProxmoxConnectionError("Proxmox username must include a realm, e.g. root@pam or user@pve.")
if self._should_use_sdk():
self._connect_with_sdk()
return self
self._connect_with_requests()
return self
def __exit__(self, exc_type, exc_value, traceback):
if self.sdk is not None:
self.sdk.close()
if self.session is not None:
self.session.close()
@staticmethod
def _parse_endpoint_address(host, port):
raw_host = str(host or "").strip()
resolved_port = int(port or 8006)
if not raw_host:
return raw_host, resolved_port
parsed = urlsplit(raw_host if "://" in raw_host else f"//{raw_host}")
parsed_host = parsed.hostname
if parsed.port:
resolved_port = parsed.port
if parsed_host:
return parsed_host.strip("[]"), resolved_port
return raw_host.strip("[]"), resolved_port
@staticmethod
def _format_host_for_url(host):
if ":" in host and not host.startswith("["):
return f"[{host}]"
return host
def _should_use_sdk(self):
# proxmox-sdk 0.0.12 builds password ticket URLs without /api2/json.
# Keep it for stateless API-token auth, where no ticket endpoint is used.
return (
ProxmoxSDK is not None
and self.endpoint.auth_method == EndpointAuthMethodChoices.METHOD_API_TOKEN
)
def _connect_with_sdk(self):
user = (self.endpoint.username or "").strip()
kwargs = {
"host": self.host,
"backend": "https",
"service": "PVE",
"port": self.port,
"user": user,
"verify_ssl": self.endpoint.validate_ssl,
"timeout": self.read_timeout,
"connect_timeout": self.connect_timeout,
"max_retries": self.max_retries,
"retry_backoff": self.retry_backoff,
}
if self.endpoint.auth_method == EndpointAuthMethodChoices.METHOD_API_TOKEN:
user, token_name, token_value = self._normalized_token_credentials()
kwargs["user"] = user
kwargs.update(
{
"token_name": token_name,
"token_value": token_value,
}
)
else:
kwargs["password"] = self.endpoint.password
try:
self.sdk = ProxmoxSDK.sync(**kwargs)
self.uses_sdk = True
self._get("version")
self.api_probe_succeeded = True
except ProxmoxConnectionError:
if self.sdk is not None:
self.sdk.close()
self.sdk = None
raise
except Exception as exc:
if self.sdk is not None:
self.sdk.close()
self.sdk = None
raise ProxmoxConnectionError(self._describe_sdk_error("GET", "version", exc)) from exc
def _connect_with_requests(self):
if requests is None:
raise ProxmoxConnectionError(
"Neither proxmox-sdk nor requests is installed in the NetBox Python environment."
)
self.session = requests.Session()
self.session.trust_env = False
self.session.verify = self.endpoint.validate_ssl
if HTTPAdapter is not None:
self.session.mount("https://", ProxmoxTLSCompatibilityAdapter(validate_ssl=self.endpoint.validate_ssl))
self.session.headers.update(
{
"Accept": "application/json",
"Connection": "close",
"User-Agent": "netbox-vm-import/0.2",
}
)
if not self.endpoint.validate_ssl and InsecureRequestWarning is not None:
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
self._probe_api_availability()
if self.endpoint.auth_method == EndpointAuthMethodChoices.METHOD_API_TOKEN:
user, token_name, token_value = self._normalized_token_credentials()
self.session.headers.update({"Authorization": f"PVEAPIToken={user}!{token_name}={token_value}"})
else:
self._authenticate_with_password()
def _probe_api_availability(self):
try:
self._request("GET", "version", timeout=(5, 10))
self.api_probe_succeeded = True
self.api_probe_error = ""
except ProxmoxConnectionError as exc:
self.api_probe_succeeded = False
self.api_probe_error = str(exc)
def _normalized_token_credentials(self):
user = (self.endpoint.username or "").strip()
token_name = (self.endpoint.token_name or "").strip()
token_value = (self.endpoint.password or "").strip()
parsed = self._parse_full_token(token_value)
if parsed is not None:
user, token_name, token_value = parsed
else:
parsed = self._parse_full_token(token_name)
if parsed is not None:
parsed_user, parsed_name, parsed_value = parsed
user = parsed_user or user
token_name = parsed_name
if parsed_value and not token_value:
token_value = parsed_value
elif token_name.startswith("PVEAPIToken="):
token_name = token_name.removeprefix("PVEAPIToken=").strip()
if "!" in token_name:
parsed_user, parsed_name = token_name.rsplit("!", 1)
if parsed_user:
user = parsed_user
token_name = parsed_name
if not token_name or not token_value:
raise ProxmoxConnectionError(
"Proxmox API token authentication requires a token name and token secret."
)
return user, token_name, token_value
@classmethod
def _parse_full_token(cls, value):
match = cls.FULL_TOKEN_PATTERN.match(str(value or "").strip())
if not match:
return None
return match.group("user").strip(), match.group("name").strip(), match.group("value").strip()
def _authenticate_with_password(self):
response = self._request(
"POST",
"access/ticket",
data={
"username": self.endpoint.username,
"password": self.endpoint.password,
},
)
data = response.json().get("data") or {}
ticket = data.get("ticket")
csrf_token = data.get("CSRFPreventionToken")
if not ticket:
raise ProxmoxConnectionError("Proxmox authentication did not return a ticket.")
self.session.cookies.set("PVEAuthCookie", ticket)
if csrf_token:
self.session.headers.update({"CSRFPreventionToken": csrf_token})
def _url(self, path):
return f"{self.base_url}/{path.strip('/')}"
def _request(self, method, path, timeout=None, **kwargs):
url = self._url(path)
request_timeout = timeout or self.timeout
attempts = self.max_retries + 1 if method.upper() == "GET" else 1
last_error = None
for attempt in range(attempts):
if attempt:
time.sleep(min(self.retry_backoff * (2 ** (attempt - 1)), 30.0))
try:
response = self.session.request(method, url, timeout=request_timeout, **kwargs)
if response.status_code in self.RETRY_STATUSES and attempt < attempts - 1:
last_error = ProxmoxConnectionError(
f"Proxmox API {method} {path} returned transient HTTP {response.status_code}."
)
continue
response.raise_for_status()
return response
except requests.ConnectTimeout as exc:
last_error = ProxmoxConnectionError(
f"Proxmox API {method} {path} connect timed out after {request_timeout[0]} seconds."
)
if attempt >= attempts - 1:
raise last_error from exc
except requests.ReadTimeout as exc:
detail = f"Proxmox API {method} {path} read timed out after {request_timeout[1]} seconds."
if path == "access/ticket" and self.api_probe_succeeded:
detail += " API version endpoint responded, so the delay is likely in Proxmox authentication. Check username realm (e.g. root@pam), PAM/LDAP auth, two-factor auth, or use an API token."
elif getattr(self, "api_probe_error", "") and path != "version":
detail += f" Earlier unauthenticated version probe also failed: {self.api_probe_error}"
last_error = ProxmoxConnectionError(detail)
if attempt >= attempts - 1:
raise last_error from exc
except requests.exceptions.SSLError as exc:
raise ProxmoxConnectionError(
f"Proxmox API {method} {path} TLS validation failed. Disable Validate SSL for self-signed certificates or install the CA certificate. Details: {exc}"
) from exc
except requests.RequestException as exc:
last_error = ProxmoxConnectionError(f"Proxmox API {method} {path} failed: {exc}")
if attempt >= attempts - 1:
raise last_error from exc
if last_error is not None:
raise last_error
raise ProxmoxConnectionError(f"Proxmox API {method} {path} failed without a response.")
def _sdk_get(self, path, params=None):
try:
return self.sdk(path.strip("/")).get(**(params or {}))
except Exception as exc:
raise ProxmoxConnectionError(self._describe_sdk_error("GET", path, exc)) from exc
def _describe_sdk_error(self, method, path, exc):
if SDKTimeoutError is not None and isinstance(exc, SDKTimeoutError):
return f"Proxmox SDK {method} {path} timed out after {self.read_timeout} seconds."
if SDKConnectionError is not None and isinstance(exc, SDKConnectionError):
detail = str(exc).strip() or "connection failed"
return f"Proxmox SDK {method} {path} connection failed: {detail}"
if SDKResourceException is not None and isinstance(exc, SDKResourceException):
status_code = getattr(exc, "status_code", None)
status_message = getattr(exc, "status_message", "") or ""
content = (getattr(exc, "content", "") or "").strip()
errors = getattr(exc, "errors", None)
parts = []
if status_code:
parts.append(f"HTTP {status_code} {status_message}".strip())
elif status_message:
parts.append(status_message)
if content:
parts.append(content)
if errors:
parts.append(str(errors))
detail = " - ".join(parts) if parts else str(exc)
return f"Proxmox SDK {method} {path} failed: {detail}"
detail = str(exc).strip() or "unknown error"
return f"Proxmox SDK {method} {path} failed: {detail}"
def _get(self, path, params=None):
if self.uses_sdk:
return self._sdk_get(path, params=params)
response = self._request("GET", path, params=params)
return response.json().get("data")
def iter_virtual_machines(self):
resources = self._get("cluster/resources", params={"type": "vm"}) or []
for resource in resources:
vm_type = resource.get("type")
if vm_type == "lxc" and not self.endpoint.sync_lxc_containers:
continue
if vm_type not in ("qemu", "lxc"):
continue
yield self._build_vm_data(resource)
def _build_vm_data(self, resource):
vmid = resource["vmid"]
node = resource["node"]
vm_type = resource["type"]
config = self._get(f"nodes/{node}/{vm_type}/{vmid}/config") or {}
name = resource.get("name") or config.get("name") or config.get("hostname") or f"{vm_type}-{vmid}"
status = "active" if resource.get("status") == "running" else "offline"
memory_mb = self._get_memory_mb(resource, config)
vcpus = self._get_vcpus(resource, config)
if vm_type == "qemu":
disks = self._get_qemu_disks(config)
interfaces = self._get_qemu_interfaces(config)
self._merge_qemu_agent_interfaces(node, vmid, interfaces)
guest_os = self._get_qemu_guest_os(node, vmid)
else:
disks = self._get_lxc_disks(config)
interfaces = self._get_lxc_interfaces(config)
guest_os = "Linux Container"
disk_mb = sum(disk.size_mb for disk in disks) or self._bytes_to_mb(resource.get("maxdisk"))
return VMData(
name=str(name),
status=status,
vcpus=vcpus,
memory_mb=memory_mb,
disk_mb=disk_mb,
guest_os=guest_os,
disks=disks,
interfaces=interfaces,
)
@staticmethod
def _get_memory_mb(resource, config):
memory = config.get("memory")
if memory not in (None, ""):
try:
return int(memory)
except (TypeError, ValueError):
pass
return ProxmoxClient._bytes_to_mb(resource.get("maxmem"))
@staticmethod
def _get_vcpus(resource, config):
try:
sockets = int(config.get("sockets") or 1)
cores = int(config.get("cores") or resource.get("maxcpu") or 0)
return max(sockets * cores, 0)
except (TypeError, ValueError):
return int(resource.get("maxcpu") or 0)
@staticmethod
def _bytes_to_mb(value):
try:
return round(int(value or 0) / 1024 / 1024)
except (TypeError, ValueError):
return 0
def _get_qemu_disks(self, config):
disks = []
for key, value in sorted(config.items()):
if not self.DISK_KEY_PATTERN.match(key):
continue
parsed = self._parse_proxmox_value(value)
if parsed.get("media") == "cdrom":
continue
size_mb = self._parse_size_mb(parsed.get("size"))
if size_mb <= 0:
continue
disks.append(
DiskData(
name=key,
size_mb=size_mb,
description=f"Proxmox: {str(value)[:191]}",
)
)
return disks
def _get_lxc_disks(self, config):
disks = []
for key, value in sorted(config.items()):
if not self.LXC_DISK_KEY_PATTERN.match(key):
continue
parsed = self._parse_proxmox_value(value)
size_mb = self._parse_size_mb(parsed.get("size"))
if size_mb <= 0:
continue
disks.append(
DiskData(
name=key,
size_mb=size_mb,
description=f"Proxmox: {str(value)[:191]}",
)
)
return disks
def _get_qemu_interfaces(self, config):
interfaces = []
for key, value in sorted(config.items()):
if not self.NET_KEY_PATTERN.match(key):
continue
parsed = self._parse_proxmox_value(value)
mac_address = self._extract_proxmox_mac(parsed)
if not mac_address:
continue
bridge = parsed.get("bridge")
name = f"{key} ({bridge})" if bridge else key
interfaces.append(InterfaceData(name=name[:64], mac_address=mac_address))
return interfaces
def _get_lxc_interfaces(self, config):
interfaces = []
for key, value in sorted(config.items()):
if not self.NET_KEY_PATTERN.match(key):
continue
parsed = self._parse_proxmox_value(value)
mac_address = parsed.get("hwaddr")
if not mac_address:
continue
name = parsed.get("name") or key
ip_addresses = []
for ip_key in ("ip", "ip6"):
ip_value = parsed.get(ip_key)
if ip_value and ip_value not in ("dhcp", "manual", "auto"):
ip_addresses.append(ip_value)
interfaces.append(
InterfaceData(
name=str(name)[:64],
mac_address=mac_address,
ip_addresses=ip_addresses,
)
)
return interfaces
def _merge_qemu_agent_interfaces(self, node, vmid, interfaces):
try:
response = self._get(f"nodes/{node}/qemu/{vmid}/agent/network-get-interfaces") or {}
except (requests.RequestException, ProxmoxConnectionError):
return
agent_interfaces = response.get("result") if isinstance(response, dict) else response
if not isinstance(agent_interfaces, list):
return
interfaces_by_mac = {self._normalize_mac(interface.mac_address): interface for interface in interfaces}
for agent_interface in agent_interfaces:
mac_address = agent_interface.get("hardware-address")
normalized_mac = self._normalize_mac(mac_address)
if not normalized_mac:
continue
ip_addresses = []
for ip_data in agent_interface.get("ip-addresses") or []:
ip_address = ip_data.get("ip-address")
prefix = ip_data.get("prefix")
if not ip_address:
continue
if prefix is not None:
ip_addresses.append(f"{ip_address}/{prefix}")
else:
ip_addresses.append(ip_address)
if normalized_mac in interfaces_by_mac:
interfaces_by_mac[normalized_mac].ip_addresses = ip_addresses
else:
interfaces.append(
InterfaceData(
name=str(agent_interface.get("name") or f"NIC-{normalized_mac[-5:]}")[:64],
mac_address=normalized_mac,
ip_addresses=ip_addresses,
)
)
def _get_qemu_guest_os(self, node, vmid):
try:
response = self._get(f"nodes/{node}/qemu/{vmid}/agent/get-osinfo") or {}
except (requests.RequestException, ProxmoxConnectionError):
return ""
result = response.get("result") if isinstance(response, dict) else None
if not isinstance(result, dict):
return ""
return result.get("pretty-name") or result.get("name") or ""
@staticmethod
def _parse_proxmox_value(value):
parsed = {"_raw": str(value)}
parts = str(value).split(",")
if parts:
parsed["_first"] = parts[0]
for part in parts:
if "=" in part:
key, part_value = part.split("=", 1)
parsed[key.strip()] = part_value.strip()
return parsed
@staticmethod
def _extract_proxmox_mac(parsed):
for key in ("virtio", "e1000", "e1000e", "rtl8139", "vmxnet3", "hwaddr"):
if parsed.get(key):
return parsed[key]
return None
@staticmethod
def _parse_size_mb(value):
if value in (None, ""):
return 0
match = re.match(r"^\s*([0-9.]+)\s*([KMGTPE]?)(?:i?B)?\s*$", str(value), re.IGNORECASE)
if not match:
return 0
amount = float(match.group(1))
unit = match.group(2).upper()
factors = {
"": 1,
"K": 1 / 1024,
"M": 1,
"G": 1024,
"T": 1024 * 1024,
"P": 1024 * 1024 * 1024,
"E": 1024 * 1024 * 1024 * 1024,
}
if not unit and amount > 1024 * 1024:
return round(amount / 1024 / 1024)
return round(amount * factors.get(unit, 1))
@staticmethod
def _normalize_mac(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
class VMwareImporter: class VMwareImporter:
def __init__(self, endpoint, logger): def __init__(self, endpoint, logger):
self.endpoint = endpoint self.endpoint = endpoint
self.logger = logger self.logger = logger
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.provider_name = "Proxmox" if endpoint.provider == EndpointProviderChoices.PROVIDER_PROXMOX else "VMware" self.provider_name = "VMware"
self.interface_content_type = None self.interface_content_type = None
self.mac_address_content_type = None self.mac_address_content_type = None
def sync(self): def sync(self):
if self.endpoint.provider != EndpointProviderChoices.PROVIDER_VMWARE:
raise VMwareConnectionError("Legacy Proxmox endpoints are no longer supported. Delete this endpoint.")
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) self.mac_address_content_type = ContentType.objects.get_for_model(VMInterface)
client_class = ProxmoxClient if self.endpoint.provider == EndpointProviderChoices.PROVIDER_PROXMOX else VMwareClient
with client_class(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():
result.seen += 1 result.seen += 1
@@ -1,6 +1,12 @@
{% extends 'generic/object.html' %} {% extends 'generic/object.html' %}
{% block content %} {% block content %}
{% if object.provider != "vmware" %}
<div class="alert alert-warning" role="alert">
This is a legacy Proxmox endpoint from an older plugin version. Proxmox sync is no longer supported; this endpoint can be deleted.
</div>
{% endif %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
@@ -22,16 +28,6 @@
<th scope="row">Username</th> <th scope="row">Username</th>
<td>{{ object.username }}</td> <td>{{ object.username }}</td>
</tr> </tr>
<tr>
<th scope="row">Auth method</th>
<td>{{ object.get_auth_method_display }}</td>
</tr>
{% if object.token_name %}
<tr>
<th scope="row">Token name</th>
<td>{{ object.token_name }}</td>
</tr>
{% endif %}
<tr> <tr>
<th scope="row">Enabled</th> <th scope="row">Enabled</th>
<td>{{ object.enabled|yesno:"Yes,No" }}</td> <td>{{ object.enabled|yesno:"Yes,No" }}</td>
@@ -40,18 +36,6 @@
<th scope="row">Validate SSL</th> <th scope="row">Validate SSL</th>
<td>{{ object.validate_ssl|yesno:"Yes,No" }}</td> <td>{{ object.validate_ssl|yesno:"Yes,No" }}</td>
</tr> </tr>
<tr>
<th scope="row">API timeout</th>
<td>{{ object.request_timeout_seconds }} seconds</td>
</tr>
<tr>
<th scope="row">Connect timeout</th>
<td>{{ object.connect_timeout_seconds }} seconds</td>
</tr>
<tr>
<th scope="row">GET retries</th>
<td>{{ object.max_retries }} retries, {{ object.retry_backoff_seconds }}s backoff</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@@ -110,6 +94,7 @@
<td>{{ object.last_message|default:"-" }}</td> <td>{{ object.last_message|default:"-" }}</td>
</tr> </tr>
</table> </table>
{% if object.provider == "vmware" %}
{% if perms.netbox_vmware_importer.change_vcenterendpoint %} {% if perms.netbox_vmware_importer.change_vcenterendpoint %}
<div class="card-footer"> <div class="card-footer">
<form method="post" action="{% url 'plugins:netbox_vmware_importer:vcenterendpoint_sync' pk=object.pk %}"> <form method="post" action="{% url 'plugins:netbox_vmware_importer:vcenterendpoint_sync' pk=object.pk %}">
@@ -120,6 +105,7 @@
</form> </form>
</div> </div>
{% endif %} {% endif %}
{% endif %}
</div> </div>
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
+6 -1
View File
@@ -8,6 +8,7 @@ from netbox.views import generic
from .filtersets import VCenterEndpointFilterSet from .filtersets import VCenterEndpointFilterSet
from .forms import VCenterEndpointFilterForm, VCenterEndpointForm from .forms import VCenterEndpointFilterForm, VCenterEndpointForm
from .jobs import SyncVCenterEndpointJob from .jobs import SyncVCenterEndpointJob
from .choices import EndpointProviderChoices
from .models import VCenterEndpoint from .models import VCenterEndpoint
from .tables import VCenterEndpointTable from .tables import VCenterEndpointTable
@@ -29,7 +30,7 @@ class VCenterEndpointView(generic.ObjectView):
class VCenterEndpointEditView(generic.ObjectEditView): class VCenterEndpointEditView(generic.ObjectEditView):
queryset = VCenterEndpoint.objects.all() queryset = VCenterEndpoint.objects.filter(provider=EndpointProviderChoices.PROVIDER_VMWARE)
form = VCenterEndpointForm form = VCenterEndpointForm
@@ -44,6 +45,10 @@ class VCenterEndpointSyncView(View):
if not request.user.has_perm("netbox_vmware_importer.change_vcenterendpoint"): if not request.user.has_perm("netbox_vmware_importer.change_vcenterendpoint"):
return HttpResponseForbidden() return HttpResponseForbidden()
if endpoint.provider != EndpointProviderChoices.PROVIDER_VMWARE:
messages.error(request, _("Legacy Proxmox endpoints can only be deleted."))
return redirect(endpoint)
SyncVCenterEndpointJob.enqueue(instance=endpoint) SyncVCenterEndpointJob.enqueue(instance=endpoint)
endpoint.mark_queued() endpoint.mark_queued()
messages.success(request, _("VM sync job queued for %(endpoint)s.") % {"endpoint": endpoint}) messages.success(request, _("VM sync job queued for %(endpoint)s.") % {"endpoint": endpoint})
+2 -4
View File
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-vmware-importer" name = "netbox-vmware-importer"
version = "0.2.7" version = "0.3.0"
description = "NetBox plugin to synchronize VMware vSphere and Proxmox VE 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"
authors = [ authors = [
@@ -22,8 +22,6 @@ classifiers = [
dependencies = [ dependencies = [
"pyvmomi>=8.0.3", "pyvmomi>=8.0.3",
"cryptography>=42.0", "cryptography>=42.0",
"requests>=2.32",
"proxmox-sdk==0.0.12",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]