Add Proxmox VE sync support
This commit is contained in:
@@ -11,3 +11,4 @@
|
|||||||
| 0.1.6 | 4.4.0 | 4.6.x |
|
| 0.1.6 | 4.4.0 | 4.6.x |
|
||||||
| 0.1.7 | 4.4.0 | 4.6.x |
|
| 0.1.7 | 4.4.0 | 4.6.x |
|
||||||
| 0.1.8 | 4.4.0 | 4.6.x |
|
| 0.1.8 | 4.4.0 | 4.6.x |
|
||||||
|
| 0.2.0 | 4.4.0 | 4.6.x |
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
# NetBox VMware Importer
|
# NetBox VMware & Proxmox Importer
|
||||||
|
|
||||||
NetBox plugin zum Synchronisieren von VMware vSphere/vCenter VMs nach NetBox.
|
NetBox plugin zum Synchronisieren von VMware vSphere/vCenter und Proxmox VE 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
|
||||||
- Import von VM-Name, Status, vCPU, RAM, Disk, Plattform, Interfaces, MAC-Adressen und IPs
|
- Import von VM-Name, Status, vCPU, RAM, Disk, Plattform, Interfaces, MAC-Adressen und IPs
|
||||||
- MAC-Adressen werden als NetBox MAC Address Objekte am VM-Interface angelegt und als primaere MAC gesetzt
|
- MAC-Adressen werden als NetBox MAC Address Objekte am VM-Interface angelegt und als primaere MAC gesetzt
|
||||||
- Virtuelle VMware-Festplatten werden als NetBox Virtual Disks mit Groesse und Backing-Info dokumentiert
|
- Virtuelle Festplatten werden als NetBox Virtual Disks mit Groesse und Backing-Info dokumentiert
|
||||||
- Primaere IPv4/IPv6 wird anhand der ersten gefundenen Gast-IP gesetzt
|
- Primaere IPv4/IPv6 wird anhand der ersten gefundenen Gast-IP gesetzt
|
||||||
- Multi-Tenant-sicherer VM-Abgleich ueber `name + cluster`
|
- Multi-Tenant-sicherer VM-Abgleich ueber `name + cluster`
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -43,11 +44,13 @@ sudo systemctl restart netbox netbox-rq
|
|||||||
|
|
||||||
## Nutzung
|
## Nutzung
|
||||||
|
|
||||||
1. In NetBox unter `VMware Import > vCenter Endpoints` ein Ziel anlegen.
|
1. In NetBox unter `VM Import > Endpoints` ein Ziel anlegen.
|
||||||
2. vCenter Host, Port, Benutzername und Passwort eintragen.
|
2. Provider auswaehlen.
|
||||||
3. Tenant, Site und Cluster fuer den Kunden auswaehlen.
|
3. VMware: vCenter Host, Port, Benutzername und Passwort eintragen.
|
||||||
4. Optional Regex-Filter und Sync-Intervall setzen.
|
4. Proxmox: Proxmox Host, Port `8006`, Benutzername und Passwort oder API-Token eintragen.
|
||||||
5. Auf der Detailseite `Sync jetzt starten` ausfuehren.
|
5. Tenant, Site und Cluster fuer den Kunden auswaehlen.
|
||||||
|
6. Optional Regex-Filter und Sync-Intervall setzen.
|
||||||
|
7. Auf der Detailseite `Sync jetzt starten` ausfuehren.
|
||||||
|
|
||||||
Wenn `Sync interval minutes` gesetzt ist, prueft ein Systemjob alle fuenf Minuten, welche Endpoints faellig sind, und stellt die eigentlichen Sync-Jobs in die Queue.
|
Wenn `Sync interval minutes` gesetzt ist, prueft ein Systemjob alle fuenf Minuten, welche Endpoints faellig sind, und stellt die eigentlichen Sync-Jobs in die Queue.
|
||||||
|
|
||||||
@@ -55,10 +58,10 @@ Ab Version `0.1.8` wird beim Speichern eines Endpoints zusaetzlich ein wiederkeh
|
|||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
Das vCenter-Passwort wird verschluesselt in der Plugin-Tabelle gespeichert. Der Schluessel wird aus `SECRET_KEY` abgeleitet. Wenn `SECRET_KEY` rotiert wird, muessen die vCenter-Passwoerter in den Endpoints erneut gesetzt werden.
|
Das 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
## VMware-Datenqualitaet
|
## Datenqualitaet
|
||||||
|
|
||||||
IP-Adressen kommen aus den VMware Guest Tools. Ohne laufende bzw. aktuelle VMware Tools koennen Interfaces und IPs fehlen oder veraltet sein.
|
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.
|
||||||
|
|||||||
@@ -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 = "VMware Importer"
|
verbose_name = "Virtualization Importer"
|
||||||
description = "Synchronize VMware vSphere virtual machines into NetBox."
|
description = "Synchronize VMware vSphere and Proxmox VE virtual machines into NetBox."
|
||||||
version = "0.1.8"
|
version = "0.2.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"
|
||||||
|
|||||||
@@ -16,3 +16,23 @@ class SyncStatusChoices(ChoiceSet):
|
|||||||
(STATUS_SUCCESS, _("Success"), "green"),
|
(STATUS_SUCCESS, _("Success"), "green"),
|
||||||
(STATUS_FAILED, _("Failed"), "red"),
|
(STATUS_FAILED, _("Failed"), "red"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointProviderChoices(ChoiceSet):
|
||||||
|
PROVIDER_VMWARE = "vmware"
|
||||||
|
PROVIDER_PROXMOX = "proxmox"
|
||||||
|
|
||||||
|
CHOICES = [
|
||||||
|
(PROVIDER_VMWARE, _("VMware vSphere"), "blue"),
|
||||||
|
(PROVIDER_PROXMOX, _("Proxmox VE"), "orange"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointAuthMethodChoices(ChoiceSet):
|
||||||
|
METHOD_PASSWORD = "password"
|
||||||
|
METHOD_API_TOKEN = "api_token"
|
||||||
|
|
||||||
|
CHOICES = [
|
||||||
|
(METHOD_PASSWORD, _("Username/password"), "blue"),
|
||||||
|
(METHOD_API_TOKEN, _("API token"), "green"),
|
||||||
|
]
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ def decrypt_secret(value):
|
|||||||
try:
|
try:
|
||||||
return _fernet().decrypt(value.encode("ascii")).decode("utf-8")
|
return _fernet().decrypt(value.encode("ascii")).decode("utf-8")
|
||||||
except InvalidToken as exc:
|
except InvalidToken as exc:
|
||||||
raise ValueError("Stored VMware password cannot be decrypted. Re-enter it on the endpoint.") from exc
|
raise ValueError("Stored endpoint password cannot be decrypted. Re-enter it on the endpoint.") from exc
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from netbox.filtersets import NetBoxModelFilterSet
|
|||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from virtualization.models import Cluster
|
from virtualization.models import Cluster
|
||||||
|
|
||||||
from .choices import SyncStatusChoices
|
from .choices import EndpointProviderChoices, SyncStatusChoices
|
||||||
from .models import VCenterEndpoint
|
from .models import VCenterEndpoint
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +25,9 @@ class VCenterEndpointFilterSet(NetBoxModelFilterSet):
|
|||||||
last_status = django_filters.MultipleChoiceFilter(
|
last_status = django_filters.MultipleChoiceFilter(
|
||||||
choices=SyncStatusChoices,
|
choices=SyncStatusChoices,
|
||||||
)
|
)
|
||||||
|
provider = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=EndpointProviderChoices,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VCenterEndpoint
|
model = VCenterEndpoint
|
||||||
@@ -33,6 +36,7 @@ class VCenterEndpointFilterSet(NetBoxModelFilterSet):
|
|||||||
"name",
|
"name",
|
||||||
"slug",
|
"slug",
|
||||||
"enabled",
|
"enabled",
|
||||||
|
"provider",
|
||||||
"host",
|
"host",
|
||||||
"tenant_id",
|
"tenant_id",
|
||||||
"site_id",
|
"site_id",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from utilities.forms.fields import (
|
|||||||
from utilities.forms.rendering import FieldSet
|
from utilities.forms.rendering import FieldSet
|
||||||
from virtualization.models import Cluster
|
from virtualization.models import Cluster
|
||||||
|
|
||||||
from .choices import SyncStatusChoices
|
from .choices import EndpointAuthMethodChoices, EndpointProviderChoices, SyncStatusChoices
|
||||||
from .models import VCenterEndpoint
|
from .models import VCenterEndpoint
|
||||||
|
|
||||||
|
|
||||||
@@ -29,8 +29,16 @@ 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"),
|
label=_("Password / token secret"),
|
||||||
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."),
|
||||||
@@ -38,7 +46,7 @@ class VCenterEndpointForm(NetBoxModelForm):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet("name", "slug", "enabled", "comments", name=_("Endpoint")),
|
FieldSet("name", "slug", "enabled", "comments", name=_("Endpoint")),
|
||||||
FieldSet("host", "port", "username", "password", "validate_ssl", name=_("VMware connection")),
|
FieldSet("provider", "host", "port", "username", "auth_method", "token_name", "password", "validate_ssl", name=_("Connection")),
|
||||||
FieldSet("tenant", "site", "cluster", name=_("NetBox target")),
|
FieldSet("tenant", "site", "cluster", name=_("NetBox target")),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
"include_name_regex",
|
"include_name_regex",
|
||||||
@@ -47,6 +55,7 @@ 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"),
|
||||||
),
|
),
|
||||||
@@ -60,9 +69,12 @@ 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",
|
||||||
"tenant",
|
"tenant",
|
||||||
@@ -74,6 +86,7 @@ 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",
|
||||||
@@ -133,7 +146,12 @@ class VCenterEndpointFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_("Last status"),
|
label=_("Last status"),
|
||||||
)
|
)
|
||||||
|
provider = forms.MultipleChoiceField(
|
||||||
|
choices=EndpointProviderChoices,
|
||||||
|
required=False,
|
||||||
|
label=_("Provider"),
|
||||||
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet("q", "enabled", "tenant_id", "site_id", "cluster_id", "last_status", name=_("Endpoint")),
|
FieldSet("q", "enabled", "provider", "tenant_id", "site_id", "cluster_id", "last_status", name=_("Endpoint")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def schedule_endpoint_sync(endpoint):
|
|||||||
|
|
||||||
class SyncVCenterEndpointJob(JobRunner):
|
class SyncVCenterEndpointJob(JobRunner):
|
||||||
class Meta:
|
class Meta:
|
||||||
name = "VMware VM synchronization"
|
name = "VM synchronization"
|
||||||
|
|
||||||
def run(self, *args, **kwargs):
|
def run(self, *args, **kwargs):
|
||||||
endpoint = self.job.object
|
endpoint = self.job.object
|
||||||
@@ -45,7 +45,7 @@ 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.")
|
||||||
|
|
||||||
self.logger.info("Starting VMware 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()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -82,7 +82,7 @@ class SyncVCenterEndpointJob(JobRunner):
|
|||||||
endpoint.mark_success(result)
|
endpoint.mark_success(result)
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
(
|
(
|
||||||
"VMware sync finished for %s: %s VM(s), %s created, %s updated, %s skipped; "
|
"VM sync finished for %s: %s VM(s), %s created, %s updated, %s skipped; "
|
||||||
"virtual disks: %s created, %s updated, %s deleted; "
|
"virtual disks: %s created, %s updated, %s deleted; "
|
||||||
"MAC addresses: %s created, %s updated, %s deleted."
|
"MAC addresses: %s created, %s updated, %s deleted."
|
||||||
),
|
),
|
||||||
@@ -103,7 +103,7 @@ class SyncVCenterEndpointJob(JobRunner):
|
|||||||
@system_job(interval=5)
|
@system_job(interval=5)
|
||||||
class ScheduleDueVCenterSyncsJob(JobRunner):
|
class ScheduleDueVCenterSyncsJob(JobRunner):
|
||||||
class Meta:
|
class Meta:
|
||||||
name = "Schedule due VMware VM synchronizations"
|
name = "Schedule due VM synchronizations"
|
||||||
|
|
||||||
def run(self, *args, **kwargs):
|
def run(self, *args, **kwargs):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
@@ -120,4 +120,4 @@ class ScheduleDueVCenterSyncsJob(JobRunner):
|
|||||||
if schedule_endpoint_sync(endpoint):
|
if schedule_endpoint_sync(endpoint):
|
||||||
scheduled += 1
|
scheduled += 1
|
||||||
|
|
||||||
self.logger.info("Ensured %s VMware endpoint sync schedule(s).", scheduled)
|
self.logger.info("Ensured %s endpoint sync schedule(s).", scheduled)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("netbox_vmware_importer", "0002_align_netbox_46_model_state"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="vcenterendpoint",
|
||||||
|
name="host",
|
||||||
|
field=models.CharField(help_text="vCenter, ESXi, or Proxmox VE hostname or IP address", max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="vcenterendpoint",
|
||||||
|
name="validate_ssl",
|
||||||
|
field=models.BooleanField(default=False, help_text="Validate the endpoint TLS certificate."),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="vcenterendpoint",
|
||||||
|
name="cluster",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text="NetBox cluster into which hypervisor VMs will be synchronized.",
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="vmware_import_endpoints",
|
||||||
|
to="virtualization.cluster",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="vcenterendpoint",
|
||||||
|
name="provider",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("vmware", "VMware vSphere"),
|
||||||
|
("proxmox", "Proxmox VE"),
|
||||||
|
],
|
||||||
|
default="vmware",
|
||||||
|
max_length=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="vcenterendpoint",
|
||||||
|
name="auth_method",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("password", "Username/password"),
|
||||||
|
("api_token", "API token"),
|
||||||
|
],
|
||||||
|
default="password",
|
||||||
|
max_length=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="vcenterendpoint",
|
||||||
|
name="token_name",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Proxmox API token ID, e.g. netbox-import. The token secret is stored in the password field.",
|
||||||
|
max_length=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="vcenterendpoint",
|
||||||
|
name="sync_lxc_containers",
|
||||||
|
field=models.BooleanField(default=True, verbose_name="Sync Proxmox LXC containers"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from netbox.models import NetBoxModel
|
from netbox.models import NetBoxModel
|
||||||
from netbox.models.features import JobsMixin
|
from netbox.models.features import JobsMixin
|
||||||
|
|
||||||
from .choices import SyncStatusChoices
|
from .choices import EndpointAuthMethodChoices, EndpointProviderChoices, SyncStatusChoices
|
||||||
from .crypto import decrypt_secret, encrypt_secret
|
from .crypto import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
|
||||||
@@ -26,9 +26,14 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
|
|||||||
enabled = models.BooleanField(
|
enabled = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
)
|
)
|
||||||
|
provider = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=EndpointProviderChoices,
|
||||||
|
default=EndpointProviderChoices.PROVIDER_VMWARE,
|
||||||
|
)
|
||||||
host = models.CharField(
|
host = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
help_text=_("vCenter or ESXi hostname or IP address"),
|
help_text=_("vCenter, ESXi, or Proxmox VE hostname or IP address"),
|
||||||
)
|
)
|
||||||
port = models.PositiveIntegerField(
|
port = models.PositiveIntegerField(
|
||||||
default=443,
|
default=443,
|
||||||
@@ -37,13 +42,23 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
|
|||||||
username = models.CharField(
|
username = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
)
|
)
|
||||||
|
auth_method = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=EndpointAuthMethodChoices,
|
||||||
|
default=EndpointAuthMethodChoices.METHOD_PASSWORD,
|
||||||
|
)
|
||||||
|
token_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Proxmox API token ID, e.g. netbox-import. The token secret is stored in the password field."),
|
||||||
|
)
|
||||||
password_ciphertext = models.TextField(
|
password_ciphertext = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
)
|
||||||
validate_ssl = models.BooleanField(
|
validate_ssl = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_("Validate the VMware endpoint certificate."),
|
help_text=_("Validate the endpoint TLS certificate."),
|
||||||
)
|
)
|
||||||
tenant = models.ForeignKey(
|
tenant = models.ForeignKey(
|
||||||
to="tenancy.Tenant",
|
to="tenancy.Tenant",
|
||||||
@@ -63,7 +78,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 VMware VMs will be synchronized."),
|
help_text=_("NetBox cluster into which hypervisor VMs will be synchronized."),
|
||||||
)
|
)
|
||||||
include_name_regex = models.CharField(
|
include_name_regex = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@@ -88,6 +103,10 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
|
|||||||
sync_primary_ips = models.BooleanField(
|
sync_primary_ips = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
)
|
)
|
||||||
|
sync_lxc_containers = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name=_("Sync Proxmox LXC containers"),
|
||||||
|
)
|
||||||
update_existing = models.BooleanField(
|
update_existing = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
help_text=_("Update existing VMs matched by name and cluster."),
|
help_text=_("Update existing VMs matched by name and cluster."),
|
||||||
@@ -154,9 +173,12 @@ 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",
|
||||||
"tenant",
|
"tenant",
|
||||||
"site",
|
"site",
|
||||||
@@ -167,6 +189,7 @@ 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",
|
||||||
@@ -175,8 +198,8 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
verbose_name = _("vCenter endpoint")
|
verbose_name = _("virtualization endpoint")
|
||||||
verbose_name_plural = _("vCenter endpoints")
|
verbose_name_plural = _("virtualization endpoints")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -207,6 +230,14 @@ 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.auth_method != EndpointAuthMethodChoices.METHOD_PASSWORD:
|
||||||
|
raise ValidationError({"auth_method": _("VMware endpoints currently support username/password authentication only.")})
|
||||||
|
|
||||||
|
if self.provider == EndpointProviderChoices.PROVIDER_PROXMOX:
|
||||||
|
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
|
||||||
self.last_message = _("Sync job queued.")
|
self.last_message = _("Sync job queued.")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
|
|||||||
|
|
||||||
endpoint_item = PluginMenuItem(
|
endpoint_item = PluginMenuItem(
|
||||||
link="plugins:netbox_vmware_importer:vcenterendpoint_list",
|
link="plugins:netbox_vmware_importer:vcenterendpoint_list",
|
||||||
link_text="vCenter Endpoints",
|
link_text="Endpoints",
|
||||||
permissions=["netbox_vmware_importer.view_vcenterendpoint"],
|
permissions=["netbox_vmware_importer.view_vcenterendpoint"],
|
||||||
buttons=(
|
buttons=(
|
||||||
PluginMenuButton(
|
PluginMenuButton(
|
||||||
@@ -18,9 +18,9 @@ endpoint_item = PluginMenuItem(
|
|||||||
)
|
)
|
||||||
|
|
||||||
menu = PluginMenu(
|
menu = PluginMenu(
|
||||||
label="VMware Import",
|
label="VM Import",
|
||||||
groups=(
|
groups=(
|
||||||
("VMware", (endpoint_item,)),
|
("Providers", (endpoint_item,)),
|
||||||
),
|
),
|
||||||
icon_class="mdi mdi-cloud-sync",
|
icon_class="mdi mdi-cloud-sync",
|
||||||
)
|
)
|
||||||
|
|||||||
+366
-11
@@ -10,6 +10,13 @@ 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
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError: # pragma: no cover - handled at runtime inside NetBox
|
||||||
|
requests = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from pyVim.connect import Disconnect, SmartConnect
|
from pyVim.connect import Disconnect, SmartConnect
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
@@ -70,6 +77,10 @@ class VMwareConnectionError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProxmoxConnectionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class VMwareClient:
|
class VMwareClient:
|
||||||
def __init__(self, endpoint):
|
def __init__(self, endpoint):
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
@@ -210,12 +221,352 @@ 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+$")
|
||||||
|
|
||||||
|
def __init__(self, endpoint):
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.session = None
|
||||||
|
self.base_url = f"https://{endpoint.host}:{endpoint.port}/api2/json"
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if requests is None:
|
||||||
|
raise ProxmoxConnectionError("requests is not installed in the NetBox Python environment.")
|
||||||
|
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.verify = self.endpoint.validate_ssl
|
||||||
|
|
||||||
|
if self.endpoint.auth_method == EndpointAuthMethodChoices.METHOD_API_TOKEN:
|
||||||
|
token_id = self.endpoint.token_name if "!" in self.endpoint.token_name else f"{self.endpoint.username}!{self.endpoint.token_name}"
|
||||||
|
self.session.headers.update({"Authorization": f"PVEAPIToken={token_id}={self.endpoint.password}"})
|
||||||
|
else:
|
||||||
|
self._authenticate_with_password()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
if self.session is not None:
|
||||||
|
self.session.close()
|
||||||
|
|
||||||
|
def _authenticate_with_password(self):
|
||||||
|
response = self.session.post(
|
||||||
|
self._url("access/ticket"),
|
||||||
|
data={
|
||||||
|
"username": self.endpoint.username,
|
||||||
|
"password": self.endpoint.password,
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
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 _get(self, path, params=None):
|
||||||
|
response = self.session.get(self._url(path), params=params, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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.interface_content_type = None
|
self.interface_content_type = None
|
||||||
self.mac_address_content_type = None
|
self.mac_address_content_type = None
|
||||||
|
|
||||||
@@ -224,8 +575,9 @@ class VMwareImporter:
|
|||||||
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 VMwareClient(self.endpoint) as client:
|
with client_class(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
|
||||||
|
|
||||||
@@ -337,15 +689,18 @@ class VMwareImporter:
|
|||||||
result.virtual_disks_updated += 1
|
result.virtual_disks_updated += 1
|
||||||
self.logger.info("Updated virtual disk %s on %s", disk_data.name, nb_vm.name)
|
self.logger.info("Updated virtual disk %s on %s", disk_data.name, nb_vm.name)
|
||||||
|
|
||||||
stale_disks = VirtualDisk.objects.filter(
|
stale_count = 0
|
||||||
virtual_machine=nb_vm,
|
for description_prefix in (f"{self.provider_name}:",):
|
||||||
description__startswith="VMware:",
|
stale_disks = VirtualDisk.objects.filter(
|
||||||
).exclude(name__in=synced_names)
|
virtual_machine=nb_vm,
|
||||||
stale_count = stale_disks.count()
|
description__startswith=description_prefix,
|
||||||
if stale_count:
|
).exclude(name__in=synced_names)
|
||||||
|
stale_count += stale_disks.count()
|
||||||
stale_disks.delete()
|
stale_disks.delete()
|
||||||
|
|
||||||
|
if stale_count:
|
||||||
result.virtual_disks_deleted += stale_count
|
result.virtual_disks_deleted += stale_count
|
||||||
self.logger.info("Deleted %s stale VMware virtual disk(s) from %s", stale_count, nb_vm.name)
|
self.logger.info("Deleted %s stale %s virtual disk(s) from %s", stale_count, self.provider_name, nb_vm.name)
|
||||||
|
|
||||||
disk_total = nb_vm.virtualdisks.aggregate(total=Sum("size", default=0))["total"] or vm_data.disk_mb
|
disk_total = nb_vm.virtualdisks.aggregate(total=Sum("size", default=0))["total"] or vm_data.disk_mb
|
||||||
if nb_vm.disk != disk_total:
|
if nb_vm.disk != disk_total:
|
||||||
@@ -422,7 +777,7 @@ class VMwareImporter:
|
|||||||
assigned_object=vm_interface,
|
assigned_object=vm_interface,
|
||||||
)
|
)
|
||||||
|
|
||||||
mac_obj.description = "VMware reported MAC address"
|
mac_obj.description = f"{self.provider_name} reported MAC address"
|
||||||
mac_obj.full_clean()
|
mac_obj.full_clean()
|
||||||
mac_obj.save()
|
mac_obj.save()
|
||||||
|
|
||||||
@@ -442,13 +797,13 @@ class VMwareImporter:
|
|||||||
stale_macs = MACAddress.objects.filter(
|
stale_macs = MACAddress.objects.filter(
|
||||||
assigned_object_type=self.mac_address_content_type,
|
assigned_object_type=self.mac_address_content_type,
|
||||||
assigned_object_id=vm_interface.pk,
|
assigned_object_id=vm_interface.pk,
|
||||||
description="VMware reported MAC address",
|
description=f"{self.provider_name} reported MAC address",
|
||||||
).exclude(pk=mac_obj.pk)
|
).exclude(pk=mac_obj.pk)
|
||||||
stale_count = stale_macs.count()
|
stale_count = stale_macs.count()
|
||||||
if stale_count:
|
if stale_count:
|
||||||
stale_macs.delete()
|
stale_macs.delete()
|
||||||
result.mac_addresses_deleted += stale_count
|
result.mac_addresses_deleted += stale_count
|
||||||
self.logger.info("Deleted %s stale VMware MAC address(es) from interface %s", stale_count, vm_interface.name)
|
self.logger.info("Deleted %s stale %s MAC address(es) from interface %s", stale_count, self.provider_name, vm_interface.name)
|
||||||
|
|
||||||
return mac_obj
|
return mac_obj
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class VCenterEndpointTable(NetBoxTable):
|
|||||||
linkify=True,
|
linkify=True,
|
||||||
)
|
)
|
||||||
enabled = BooleanColumn()
|
enabled = BooleanColumn()
|
||||||
|
provider = ChoiceFieldColumn()
|
||||||
tenant = tables.Column(
|
tenant = tables.Column(
|
||||||
linkify=True,
|
linkify=True,
|
||||||
)
|
)
|
||||||
@@ -31,6 +32,7 @@ class VCenterEndpointTable(NetBoxTable):
|
|||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"enabled",
|
"enabled",
|
||||||
|
"provider",
|
||||||
"host",
|
"host",
|
||||||
"tenant",
|
"tenant",
|
||||||
"site",
|
"site",
|
||||||
@@ -45,6 +47,7 @@ class VCenterEndpointTable(NetBoxTable):
|
|||||||
"pk",
|
"pk",
|
||||||
"name",
|
"name",
|
||||||
"enabled",
|
"enabled",
|
||||||
|
"provider",
|
||||||
"host",
|
"host",
|
||||||
"tenant",
|
"tenant",
|
||||||
"cluster",
|
"cluster",
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
<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">
|
||||||
<h5 class="card-header">vCenter</h5>
|
<h5 class="card-header">Endpoint</h5>
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Name</th>
|
<th scope="row">Name</th>
|
||||||
<td>{{ object.name }}</td>
|
<td>{{ object.name }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Provider</th>
|
||||||
|
<td>{{ object.get_provider_display }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Host</th>
|
<th scope="row">Host</th>
|
||||||
<td>{{ object.host }}:{{ object.port }}</td>
|
<td>{{ object.host }}:{{ object.port }}</td>
|
||||||
@@ -18,6 +22,16 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -46,6 +46,6 @@ class VCenterEndpointSyncView(View):
|
|||||||
|
|
||||||
SyncVCenterEndpointJob.enqueue(instance=endpoint)
|
SyncVCenterEndpointJob.enqueue(instance=endpoint)
|
||||||
endpoint.mark_queued()
|
endpoint.mark_queued()
|
||||||
messages.success(request, _("VMware sync job queued for %(endpoint)s.") % {"endpoint": endpoint})
|
messages.success(request, _("VM sync job queued for %(endpoint)s.") % {"endpoint": endpoint})
|
||||||
|
|
||||||
return redirect(endpoint)
|
return redirect(endpoint)
|
||||||
|
|||||||
+3
-2
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-vmware-importer"
|
name = "netbox-vmware-importer"
|
||||||
version = "0.1.8"
|
version = "0.2.0"
|
||||||
description = "NetBox plugin to synchronize VMware vSphere virtual machines into NetBox."
|
description = "NetBox plugin to synchronize VMware vSphere and Proxmox VE virtual machines into NetBox."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [
|
authors = [
|
||||||
@@ -22,6 +22,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"pyvmomi>=8.0.3",
|
"pyvmomi>=8.0.3",
|
||||||
"cryptography>=42.0",
|
"cryptography>=42.0",
|
||||||
|
"requests>=2.32",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
Reference in New Issue
Block a user