upload
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
from netbox.plugins import PluginConfig
|
||||
|
||||
|
||||
class VMwareImporterConfig(PluginConfig):
|
||||
name = "netbox_vmware_importer"
|
||||
verbose_name = "VMware Importer"
|
||||
description = "Synchronize VMware vSphere virtual machines into NetBox."
|
||||
version = "0.1.0"
|
||||
author = "Internal NetBox Team"
|
||||
base_url = "vmware-importer"
|
||||
min_version = "4.4.0"
|
||||
|
||||
def ready(self):
|
||||
super().ready()
|
||||
|
||||
# Importing registers the scheduler system job with NetBox.
|
||||
from . import jobs # noqa: F401
|
||||
|
||||
|
||||
config = VMwareImporterConfig
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from utilities.choices import ChoiceSet
|
||||
|
||||
|
||||
class SyncStatusChoices(ChoiceSet):
|
||||
STATUS_NEVER = "never"
|
||||
STATUS_QUEUED = "queued"
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_SUCCESS = "success"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
CHOICES = [
|
||||
(STATUS_NEVER, _("Never synced"), "gray"),
|
||||
(STATUS_QUEUED, _("Queued"), "blue"),
|
||||
(STATUS_RUNNING, _("Running"), "cyan"),
|
||||
(STATUS_SUCCESS, _("Success"), "green"),
|
||||
(STATUS_FAILED, _("Failed"), "red"),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def _fernet():
|
||||
key = hashlib.sha256(settings.SECRET_KEY.encode("utf-8")).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
def encrypt_secret(value):
|
||||
if value in (None, ""):
|
||||
return ""
|
||||
return _fernet().encrypt(value.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_secret(value):
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return _fernet().decrypt(value.encode("ascii")).decode("utf-8")
|
||||
except InvalidToken as exc:
|
||||
raise ValueError("Stored VMware password cannot be decrypted. Re-enter it on the endpoint.") from exc
|
||||
@@ -0,0 +1,53 @@
|
||||
from django.db.models import Q
|
||||
import django_filters
|
||||
from dcim.models import Site
|
||||
from netbox.filtersets import NetBoxModelFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from virtualization.models import Cluster
|
||||
|
||||
from .choices import SyncStatusChoices
|
||||
from .models import VCenterEndpoint
|
||||
|
||||
|
||||
class VCenterEndpointFilterSet(NetBoxModelFilterSet):
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="tenant",
|
||||
queryset=Tenant.objects.all(),
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="site",
|
||||
queryset=Site.objects.all(),
|
||||
)
|
||||
cluster_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="cluster",
|
||||
queryset=Cluster.objects.all(),
|
||||
)
|
||||
last_status = django_filters.MultipleChoiceFilter(
|
||||
choices=SyncStatusChoices,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VCenterEndpoint
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"enabled",
|
||||
"host",
|
||||
"tenant_id",
|
||||
"site_id",
|
||||
"cluster_id",
|
||||
"last_status",
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value)
|
||||
| Q(slug__icontains=value)
|
||||
| Q(host__icontains=value)
|
||||
| Q(username__icontains=value)
|
||||
| Q(comments__icontains=value)
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from dcim.models import Site
|
||||
from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms.fields import (
|
||||
DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
MultipleChoiceField,
|
||||
SlugField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from virtualization.models import Cluster
|
||||
|
||||
from .choices import SyncStatusChoices
|
||||
from .models import VCenterEndpoint
|
||||
|
||||
|
||||
class VCenterEndpointForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=_("Password"),
|
||||
required=False,
|
||||
widget=forms.PasswordInput(render_value=False),
|
||||
help_text=_("Leave blank to keep the currently stored password."),
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet("name", "slug", "enabled", "comments", name=_("Endpoint")),
|
||||
FieldSet("host", "port", "username", "password", "validate_ssl", name=_("VMware connection")),
|
||||
FieldSet("tenant", "site", "cluster", name=_("NetBox target")),
|
||||
FieldSet(
|
||||
"include_name_regex",
|
||||
"exclude_name_regex",
|
||||
"sync_powered_off",
|
||||
"sync_interfaces",
|
||||
"sync_ip_addresses",
|
||||
"sync_primary_ips",
|
||||
"update_existing",
|
||||
name=_("Sync behavior"),
|
||||
),
|
||||
FieldSet("default_ipv4_prefix_length", "default_ipv6_prefix_length", "sync_interval_minutes", name=_("Automation")),
|
||||
FieldSet("tags", name=_("Tags")),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VCenterEndpoint
|
||||
fields = (
|
||||
"name",
|
||||
"slug",
|
||||
"enabled",
|
||||
"host",
|
||||
"port",
|
||||
"username",
|
||||
"password",
|
||||
"validate_ssl",
|
||||
"tenant",
|
||||
"site",
|
||||
"cluster",
|
||||
"include_name_regex",
|
||||
"exclude_name_regex",
|
||||
"sync_powered_off",
|
||||
"sync_interfaces",
|
||||
"sync_ip_addresses",
|
||||
"sync_primary_ips",
|
||||
"update_existing",
|
||||
"default_ipv4_prefix_length",
|
||||
"default_ipv6_prefix_length",
|
||||
"sync_interval_minutes",
|
||||
"comments",
|
||||
"tags",
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
password = cleaned_data.get("password")
|
||||
|
||||
if not self.instance.pk and not password:
|
||||
raise ValidationError({"password": _("A password is required for new endpoints.")})
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
obj = super().save(commit=False)
|
||||
password = self.cleaned_data.get("password")
|
||||
|
||||
if password:
|
||||
obj.set_password(password)
|
||||
|
||||
if commit:
|
||||
obj.save()
|
||||
self.save_m2m()
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
class VCenterEndpointFilterForm(NetBoxModelFilterSetForm):
|
||||
model = VCenterEndpoint
|
||||
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_("Enabled"),
|
||||
)
|
||||
|
||||
tenant_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
label=_("Tenant"),
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label=_("Site"),
|
||||
)
|
||||
cluster_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
label=_("Cluster"),
|
||||
)
|
||||
last_status = MultipleChoiceField(
|
||||
choices=SyncStatusChoices,
|
||||
required=False,
|
||||
label=_("Last status"),
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet("q", "enabled", "tenant_id", "site_id", "cluster_id", "last_status", name=_("Endpoint")),
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
from core.exceptions import JobFailed
|
||||
from django.utils import timezone
|
||||
from netbox.jobs import JobRunner, system_job
|
||||
|
||||
from .choices import SyncStatusChoices
|
||||
from .models import VCenterEndpoint
|
||||
from .sync import VMwareImporter
|
||||
|
||||
|
||||
class SyncVCenterEndpointJob(JobRunner):
|
||||
class Meta:
|
||||
name = "VMware VM synchronization"
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
endpoint = self.job.object
|
||||
if endpoint is None:
|
||||
endpoint_pk = kwargs.get("endpoint_pk")
|
||||
endpoint = VCenterEndpoint.objects.get(pk=endpoint_pk)
|
||||
|
||||
if not endpoint.enabled:
|
||||
endpoint.mark_failure("Endpoint is disabled.")
|
||||
raise JobFailed("Endpoint is disabled.")
|
||||
|
||||
self.logger.info("Starting VMware sync for %s (%s)", endpoint.name, endpoint.host)
|
||||
endpoint.mark_running()
|
||||
|
||||
try:
|
||||
result = VMwareImporter(endpoint, self.logger).sync()
|
||||
except Exception as exc:
|
||||
endpoint.mark_failure(exc)
|
||||
raise
|
||||
|
||||
if result.errors:
|
||||
endpoint.last_sync_at = timezone.now()
|
||||
endpoint.last_status = SyncStatusChoices.STATUS_FAILED
|
||||
endpoint.last_vm_count = result.seen
|
||||
endpoint.last_created_count = result.created
|
||||
endpoint.last_updated_count = result.updated
|
||||
endpoint.last_error_count = result.errors
|
||||
endpoint.last_status = SyncStatusChoices.STATUS_FAILED
|
||||
endpoint.last_message = f"Finished with {result.errors} VM error(s). Check the job log."
|
||||
endpoint.set_next_sync(endpoint.last_sync_at)
|
||||
endpoint.save(
|
||||
update_fields=(
|
||||
"last_sync_at",
|
||||
"last_status",
|
||||
"last_vm_count",
|
||||
"last_created_count",
|
||||
"last_updated_count",
|
||||
"last_error_count",
|
||||
"last_message",
|
||||
"next_sync_at",
|
||||
"last_updated",
|
||||
)
|
||||
)
|
||||
raise JobFailed(endpoint.last_message)
|
||||
|
||||
endpoint.mark_success(result)
|
||||
self.logger.info(
|
||||
"VMware sync finished for %s: %s VM(s), %s created, %s updated, %s skipped.",
|
||||
endpoint.name,
|
||||
result.synced,
|
||||
result.created,
|
||||
result.updated,
|
||||
result.skipped,
|
||||
)
|
||||
|
||||
|
||||
@system_job(interval=5)
|
||||
class ScheduleDueVCenterSyncsJob(JobRunner):
|
||||
class Meta:
|
||||
name = "Schedule due VMware VM synchronizations"
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
now = timezone.now()
|
||||
due_endpoints = VCenterEndpoint.objects.filter(
|
||||
enabled=True,
|
||||
sync_interval_minutes__isnull=False,
|
||||
).filter(next_sync_at__lte=now)
|
||||
|
||||
queued = 0
|
||||
for endpoint in due_endpoints:
|
||||
SyncVCenterEndpointJob.enqueue_once(instance=endpoint)
|
||||
endpoint.last_status = SyncStatusChoices.STATUS_QUEUED
|
||||
endpoint.last_message = "Automatic sync job queued."
|
||||
endpoint.set_next_sync(now)
|
||||
endpoint.save(update_fields=("last_status", "last_message", "next_sync_at", "last_updated"))
|
||||
queued += 1
|
||||
|
||||
self.logger.info("Queued %s VMware sync job(s).", queued)
|
||||
@@ -0,0 +1,143 @@
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("dcim", "0001_initial"),
|
||||
("tenancy", "0001_initial"),
|
||||
("virtualization", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="VCenterEndpoint",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created", models.DateTimeField(auto_now_add=True, null=True)),
|
||||
("last_updated", models.DateTimeField(auto_now=True, null=True)),
|
||||
("custom_field_data", models.JSONField(blank=True, default=dict)),
|
||||
("name", models.CharField(max_length=100, unique=True)),
|
||||
("slug", models.SlugField(max_length=100, unique=True)),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("host", models.CharField(help_text="vCenter or ESXi hostname or IP address", max_length=255)),
|
||||
(
|
||||
"port",
|
||||
models.PositiveIntegerField(
|
||||
default=443,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(65535),
|
||||
],
|
||||
),
|
||||
),
|
||||
("username", models.CharField(max_length=255)),
|
||||
("password_ciphertext", models.TextField(blank=True, editable=False)),
|
||||
("validate_ssl", models.BooleanField(default=False, help_text="Validate the VMware endpoint certificate.")),
|
||||
(
|
||||
"include_name_regex",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Only synchronize matching VM names. Leave empty to include all VMs.",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
("exclude_name_regex", models.CharField(blank=True, help_text="Skip matching VM names.", max_length=255)),
|
||||
("sync_powered_off", models.BooleanField(default=True, verbose_name="Sync powered-off VMs")),
|
||||
("sync_interfaces", models.BooleanField(default=True)),
|
||||
("sync_ip_addresses", models.BooleanField(default=True)),
|
||||
("sync_primary_ips", models.BooleanField(default=True)),
|
||||
("update_existing", models.BooleanField(default=True, help_text="Update existing VMs matched by name and cluster.")),
|
||||
(
|
||||
"default_ipv4_prefix_length",
|
||||
models.PositiveSmallIntegerField(
|
||||
default=24,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(32),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"default_ipv6_prefix_length",
|
||||
models.PositiveSmallIntegerField(
|
||||
default=64,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(128),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"sync_interval_minutes",
|
||||
models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Optional automatic sync interval in minutes. Leave empty for manual sync only.",
|
||||
null=True,
|
||||
validators=[django.core.validators.MinValueValidator(5)],
|
||||
),
|
||||
),
|
||||
("next_sync_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
("last_sync_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
("last_success_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
(
|
||||
"last_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("never", "Never synced"),
|
||||
("queued", "Queued"),
|
||||
("running", "Running"),
|
||||
("success", "Success"),
|
||||
("failed", "Failed"),
|
||||
],
|
||||
default="never",
|
||||
editable=False,
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
("last_message", models.CharField(blank=True, editable=False, max_length=500)),
|
||||
("last_vm_count", models.PositiveIntegerField(default=0, editable=False)),
|
||||
("last_created_count", models.PositiveIntegerField(default=0, editable=False)),
|
||||
("last_updated_count", models.PositiveIntegerField(default=0, editable=False)),
|
||||
("last_error_count", models.PositiveIntegerField(default=0, editable=False)),
|
||||
("comments", models.TextField(blank=True)),
|
||||
(
|
||||
"cluster",
|
||||
models.ForeignKey(
|
||||
help_text="NetBox cluster into which VMware VMs will be synchronized.",
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="vmware_import_endpoints",
|
||||
to="virtualization.cluster",
|
||||
),
|
||||
),
|
||||
(
|
||||
"site",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="vmware_import_endpoints",
|
||||
to="dcim.site",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="vmware_import_endpoints",
|
||||
to="tenancy.tenant",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "vCenter endpoint",
|
||||
"verbose_name_plural": "vCenter endpoints",
|
||||
"ordering": ("name",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from netbox.models import NetBoxModel
|
||||
from netbox.models.features import JobsMixin
|
||||
|
||||
from .choices import SyncStatusChoices
|
||||
from .crypto import decrypt_secret, encrypt_secret
|
||||
|
||||
|
||||
class VCenterEndpoint(JobsMixin, NetBoxModel):
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
default=True,
|
||||
)
|
||||
host = models.CharField(
|
||||
max_length=255,
|
||||
help_text=_("vCenter or ESXi hostname or IP address"),
|
||||
)
|
||||
port = models.PositiveIntegerField(
|
||||
default=443,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(65535)],
|
||||
)
|
||||
username = models.CharField(
|
||||
max_length=255,
|
||||
)
|
||||
password_ciphertext = models.TextField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
)
|
||||
validate_ssl = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("Validate the VMware endpoint certificate."),
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to="tenancy.Tenant",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="vmware_import_endpoints",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
site = models.ForeignKey(
|
||||
to="dcim.Site",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="vmware_import_endpoints",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
cluster = models.ForeignKey(
|
||||
to="virtualization.Cluster",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="vmware_import_endpoints",
|
||||
help_text=_("NetBox cluster into which VMware VMs will be synchronized."),
|
||||
)
|
||||
include_name_regex = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text=_("Only synchronize matching VM names. Leave empty to include all VMs."),
|
||||
)
|
||||
exclude_name_regex = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text=_("Skip matching VM names."),
|
||||
)
|
||||
sync_powered_off = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Sync powered-off VMs"),
|
||||
)
|
||||
sync_interfaces = models.BooleanField(
|
||||
default=True,
|
||||
)
|
||||
sync_ip_addresses = models.BooleanField(
|
||||
default=True,
|
||||
)
|
||||
sync_primary_ips = models.BooleanField(
|
||||
default=True,
|
||||
)
|
||||
update_existing = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_("Update existing VMs matched by name and cluster."),
|
||||
)
|
||||
default_ipv4_prefix_length = models.PositiveSmallIntegerField(
|
||||
default=24,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(32)],
|
||||
)
|
||||
default_ipv6_prefix_length = models.PositiveSmallIntegerField(
|
||||
default=64,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(128)],
|
||||
)
|
||||
sync_interval_minutes = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(5)],
|
||||
help_text=_("Optional automatic sync interval in minutes. Leave empty for manual sync only."),
|
||||
)
|
||||
next_sync_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
editable=False,
|
||||
)
|
||||
last_sync_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
editable=False,
|
||||
)
|
||||
last_success_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
editable=False,
|
||||
)
|
||||
last_status = models.CharField(
|
||||
max_length=30,
|
||||
choices=SyncStatusChoices,
|
||||
default=SyncStatusChoices.STATUS_NEVER,
|
||||
editable=False,
|
||||
)
|
||||
last_message = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
editable=False,
|
||||
)
|
||||
last_vm_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
editable=False,
|
||||
)
|
||||
last_created_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
editable=False,
|
||||
)
|
||||
last_updated_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
editable=False,
|
||||
)
|
||||
last_error_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
editable=False,
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True,
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
"enabled",
|
||||
"host",
|
||||
"port",
|
||||
"username",
|
||||
"validate_ssl",
|
||||
"tenant",
|
||||
"site",
|
||||
"cluster",
|
||||
"include_name_regex",
|
||||
"exclude_name_regex",
|
||||
"sync_powered_off",
|
||||
"sync_interfaces",
|
||||
"sync_ip_addresses",
|
||||
"sync_primary_ips",
|
||||
"update_existing",
|
||||
"default_ipv4_prefix_length",
|
||||
"default_ipv6_prefix_length",
|
||||
"sync_interval_minutes",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("name",)
|
||||
verbose_name = _("vCenter endpoint")
|
||||
verbose_name_plural = _("vCenter endpoints")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("plugins:netbox_vmware_importer:vcenterendpoint", args=[self.pk])
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
return decrypt_secret(self.password_ciphertext)
|
||||
|
||||
def set_password(self, value):
|
||||
self.password_ciphertext = encrypt_secret(value)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
for field_name in ("include_name_regex", "exclude_name_regex"):
|
||||
pattern = getattr(self, field_name)
|
||||
if not pattern:
|
||||
continue
|
||||
try:
|
||||
re.compile(pattern)
|
||||
except re.error as exc:
|
||||
raise ValidationError({field_name: _("Invalid regular expression: %(error)s") % {"error": exc}})
|
||||
|
||||
if self.site_id and self.cluster_id and getattr(self.cluster, "site_id", None):
|
||||
if self.cluster.site_id != self.site_id:
|
||||
raise ValidationError({"cluster": _("Selected cluster belongs to a different site.")})
|
||||
|
||||
def mark_queued(self):
|
||||
self.last_status = SyncStatusChoices.STATUS_QUEUED
|
||||
self.last_message = _("Sync job queued.")
|
||||
self.save(update_fields=("last_status", "last_message", "last_updated"))
|
||||
|
||||
def mark_running(self):
|
||||
self.last_sync_at = timezone.now()
|
||||
self.last_status = SyncStatusChoices.STATUS_RUNNING
|
||||
self.last_message = _("Sync job running.")
|
||||
self.save(update_fields=("last_sync_at", "last_status", "last_message", "last_updated"))
|
||||
|
||||
def mark_success(self, result):
|
||||
now = timezone.now()
|
||||
self.last_sync_at = now
|
||||
self.last_success_at = now
|
||||
self.last_status = SyncStatusChoices.STATUS_SUCCESS
|
||||
self.last_vm_count = result.seen
|
||||
self.last_created_count = result.created
|
||||
self.last_updated_count = result.updated
|
||||
self.last_error_count = result.errors
|
||||
self.last_message = _(
|
||||
"Synchronized %(synced)s VM(s), skipped %(skipped)s VM(s)."
|
||||
) % {"synced": result.synced, "skipped": result.skipped}
|
||||
self.set_next_sync(now)
|
||||
self.save(
|
||||
update_fields=(
|
||||
"last_sync_at",
|
||||
"last_success_at",
|
||||
"last_status",
|
||||
"last_vm_count",
|
||||
"last_created_count",
|
||||
"last_updated_count",
|
||||
"last_error_count",
|
||||
"last_message",
|
||||
"next_sync_at",
|
||||
"last_updated",
|
||||
)
|
||||
)
|
||||
|
||||
def mark_failure(self, message):
|
||||
now = timezone.now()
|
||||
self.last_sync_at = now
|
||||
self.last_status = SyncStatusChoices.STATUS_FAILED
|
||||
self.last_message = str(message)[:500]
|
||||
self.set_next_sync(now)
|
||||
self.save(update_fields=("last_sync_at", "last_status", "last_message", "next_sync_at", "last_updated"))
|
||||
|
||||
def set_next_sync(self, now=None):
|
||||
if self.enabled and self.sync_interval_minutes:
|
||||
self.next_sync_at = (now or timezone.now()) + timedelta(minutes=self.sync_interval_minutes)
|
||||
else:
|
||||
self.next_sync_at = None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.enabled or not self.sync_interval_minutes:
|
||||
self.next_sync_at = None
|
||||
elif not self.next_sync_at:
|
||||
self.set_next_sync()
|
||||
return super().save(*args, **kwargs)
|
||||
@@ -0,0 +1,26 @@
|
||||
from netbox.choices import ButtonColorChoices
|
||||
from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
|
||||
|
||||
|
||||
endpoint_item = PluginMenuItem(
|
||||
link="plugins:netbox_vmware_importer:vcenterendpoint_list",
|
||||
link_text="vCenter Endpoints",
|
||||
permissions=["netbox_vmware_importer.view_vcenterendpoint"],
|
||||
buttons=(
|
||||
PluginMenuButton(
|
||||
"plugins:netbox_vmware_importer:vcenterendpoint_add",
|
||||
"Add",
|
||||
"mdi mdi-plus-thick",
|
||||
ButtonColorChoices.GREEN,
|
||||
permissions=["netbox_vmware_importer.add_vcenterendpoint"],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
menu = PluginMenu(
|
||||
label="VMware Import",
|
||||
groups=(
|
||||
("VMware", (endpoint_item,)),
|
||||
),
|
||||
icon_class="mdi mdi-cloud-sync",
|
||||
)
|
||||
@@ -0,0 +1,377 @@
|
||||
import ipaddress
|
||||
import re
|
||||
import ssl
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
from dcim.models import Platform
|
||||
from ipam.models import IPAddress
|
||||
from virtualization.models import VMInterface, VirtualMachine
|
||||
|
||||
try:
|
||||
from pyVim.connect import Disconnect, SmartConnect
|
||||
from pyVmomi import vim
|
||||
except ImportError: # pragma: no cover - handled at runtime inside NetBox
|
||||
Disconnect = None
|
||||
SmartConnect = None
|
||||
vim = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterfaceData:
|
||||
name: str
|
||||
mac_address: str
|
||||
ip_addresses: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMData:
|
||||
name: str
|
||||
status: str
|
||||
vcpus: int
|
||||
memory_mb: int
|
||||
disk_mb: int
|
||||
guest_os: str = ""
|
||||
interfaces: list[InterfaceData] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
seen: int = 0
|
||||
synced: int = 0
|
||||
skipped: int = 0
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
errors: int = 0
|
||||
interfaces_created: int = 0
|
||||
interfaces_updated: int = 0
|
||||
ip_addresses_created: int = 0
|
||||
ip_addresses_updated: int = 0
|
||||
ip_conflicts: int = 0
|
||||
|
||||
|
||||
class VMwareConnectionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class VMwareClient:
|
||||
def __init__(self, endpoint):
|
||||
self.endpoint = endpoint
|
||||
self.service_instance = None
|
||||
|
||||
def __enter__(self):
|
||||
if SmartConnect is None:
|
||||
raise VMwareConnectionError("pyVmomi is not installed in the NetBox Python environment.")
|
||||
|
||||
ssl_context = None
|
||||
if not self.endpoint.validate_ssl:
|
||||
ssl_context = ssl._create_unverified_context()
|
||||
|
||||
self.service_instance = SmartConnect(
|
||||
host=self.endpoint.host,
|
||||
port=self.endpoint.port,
|
||||
user=self.endpoint.username,
|
||||
pwd=self.endpoint.password,
|
||||
sslContext=ssl_context,
|
||||
)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
if self.service_instance is not None and Disconnect is not None:
|
||||
Disconnect(self.service_instance)
|
||||
|
||||
def iter_virtual_machines(self):
|
||||
content = self.service_instance.RetrieveContent()
|
||||
container_view = content.viewManager.CreateContainerView(
|
||||
content.rootFolder,
|
||||
[vim.VirtualMachine],
|
||||
True,
|
||||
)
|
||||
|
||||
try:
|
||||
for vm_obj in container_view.view:
|
||||
yield self._build_vm_data(vm_obj)
|
||||
finally:
|
||||
container_view.Destroy()
|
||||
|
||||
def _build_vm_data(self, vm_obj):
|
||||
config = getattr(vm_obj, "config", None)
|
||||
hardware = getattr(config, "hardware", None)
|
||||
|
||||
memory_mb = int(getattr(hardware, "memoryMB", 0) or 0)
|
||||
vcpus = int(getattr(hardware, "numCPU", 0) or 0)
|
||||
disk_mb = self._get_disk_size_mb(hardware)
|
||||
guest_os = getattr(config, "guestFullName", "") or ""
|
||||
power_state = str(getattr(getattr(vm_obj, "runtime", None), "powerState", ""))
|
||||
status = "active" if "poweredOn" in power_state else "offline"
|
||||
|
||||
return VMData(
|
||||
name=vm_obj.name,
|
||||
status=status,
|
||||
vcpus=vcpus,
|
||||
memory_mb=memory_mb,
|
||||
disk_mb=disk_mb,
|
||||
guest_os=guest_os,
|
||||
interfaces=self._get_interfaces(vm_obj),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_disk_size_mb(hardware):
|
||||
disk_mb = 0
|
||||
for device in getattr(hardware, "device", []) or []:
|
||||
if isinstance(device, vim.vm.device.VirtualDisk):
|
||||
disk_mb += round((device.capacityInKB or 0) / 1024)
|
||||
return disk_mb
|
||||
|
||||
@staticmethod
|
||||
def _get_interfaces(vm_obj):
|
||||
interfaces = []
|
||||
guest = getattr(vm_obj, "guest", None)
|
||||
|
||||
for network in getattr(guest, "net", []) or []:
|
||||
mac_address = getattr(network, "macAddress", None)
|
||||
if not mac_address:
|
||||
continue
|
||||
|
||||
name = getattr(network, "device", None) or f"NIC-{mac_address[-5:].replace(':', '')}"
|
||||
interfaces.append(
|
||||
InterfaceData(
|
||||
name=name,
|
||||
mac_address=mac_address,
|
||||
ip_addresses=list(getattr(network, "ipAddress", []) or []),
|
||||
)
|
||||
)
|
||||
|
||||
return interfaces
|
||||
|
||||
|
||||
class VMwareImporter:
|
||||
def __init__(self, endpoint, logger):
|
||||
self.endpoint = endpoint
|
||||
self.logger = logger
|
||||
self.include_pattern = re.compile(endpoint.include_name_regex) if endpoint.include_name_regex else None
|
||||
self.exclude_pattern = re.compile(endpoint.exclude_name_regex) if endpoint.exclude_name_regex else None
|
||||
self.interface_content_type = None
|
||||
|
||||
def sync(self):
|
||||
result = SyncResult()
|
||||
platforms = list(Platform.objects.all())
|
||||
self.interface_content_type = ContentType.objects.get_for_model(VMInterface)
|
||||
|
||||
with VMwareClient(self.endpoint) as client:
|
||||
for vm_data in client.iter_virtual_machines():
|
||||
result.seen += 1
|
||||
|
||||
if not self._should_sync_vm(vm_data):
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self._sync_vm(vm_data, platforms, result)
|
||||
result.synced += 1
|
||||
except Exception as exc: # pragma: no cover - needs NetBox integration test
|
||||
result.errors += 1
|
||||
self.logger.error("Failed to synchronize VM %s: %s", vm_data.name, exc)
|
||||
|
||||
return result
|
||||
|
||||
def _should_sync_vm(self, vm_data):
|
||||
if self.include_pattern and not self.include_pattern.search(vm_data.name):
|
||||
return False
|
||||
if self.exclude_pattern and self.exclude_pattern.search(vm_data.name):
|
||||
return False
|
||||
if vm_data.status == "offline" and not self.endpoint.sync_powered_off:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _sync_vm(self, vm_data, platforms, result):
|
||||
nb_vm = VirtualMachine.objects.filter(
|
||||
name=vm_data.name,
|
||||
cluster=self.endpoint.cluster,
|
||||
).first()
|
||||
|
||||
created = nb_vm is None
|
||||
if created:
|
||||
nb_vm = VirtualMachine(
|
||||
name=vm_data.name,
|
||||
cluster=self.endpoint.cluster,
|
||||
)
|
||||
elif not self.endpoint.update_existing:
|
||||
result.skipped += 1
|
||||
return
|
||||
|
||||
nb_vm.status = vm_data.status
|
||||
nb_vm.vcpus = vm_data.vcpus
|
||||
nb_vm.memory = vm_data.memory_mb
|
||||
nb_vm.disk = vm_data.disk_mb
|
||||
nb_vm.tenant = self.endpoint.tenant
|
||||
nb_vm.platform = self._match_platform(vm_data.guest_os, platforms)
|
||||
|
||||
nb_vm.full_clean()
|
||||
nb_vm.save()
|
||||
|
||||
if created:
|
||||
result.created += 1
|
||||
self.logger.info("Created VM %s", vm_data.name)
|
||||
else:
|
||||
result.updated += 1
|
||||
self.logger.info("Updated VM %s", vm_data.name)
|
||||
|
||||
if self.endpoint.sync_interfaces:
|
||||
primary_ipv4, primary_ipv6 = self._sync_interfaces(nb_vm, vm_data, result)
|
||||
self._set_primary_ips(nb_vm, primary_ipv4, primary_ipv6)
|
||||
|
||||
@staticmethod
|
||||
def _match_platform(guest_os, platforms):
|
||||
if not guest_os:
|
||||
return None
|
||||
|
||||
guest_os_lower = guest_os.lower()
|
||||
for platform in platforms:
|
||||
if platform.name.lower() in guest_os_lower:
|
||||
return platform
|
||||
|
||||
return None
|
||||
|
||||
def _sync_interfaces(self, nb_vm, vm_data, result):
|
||||
primary_ipv4 = None
|
||||
primary_ipv6 = None
|
||||
|
||||
for interface_data in vm_data.interfaces:
|
||||
vm_interface = VMInterface.objects.filter(
|
||||
virtual_machine=nb_vm,
|
||||
name=interface_data.name,
|
||||
).first()
|
||||
created = vm_interface is None
|
||||
|
||||
if created:
|
||||
vm_interface = VMInterface(
|
||||
virtual_machine=nb_vm,
|
||||
name=interface_data.name,
|
||||
)
|
||||
|
||||
vm_interface.mac_address = interface_data.mac_address
|
||||
vm_interface.enabled = True
|
||||
vm_interface.full_clean()
|
||||
vm_interface.save()
|
||||
|
||||
if created:
|
||||
result.interfaces_created += 1
|
||||
self.logger.info("Created interface %s on %s", interface_data.name, nb_vm.name)
|
||||
else:
|
||||
result.interfaces_updated += 1
|
||||
self.logger.info("Updated interface %s on %s", interface_data.name, nb_vm.name)
|
||||
|
||||
if not self.endpoint.sync_ip_addresses:
|
||||
continue
|
||||
|
||||
for raw_ip in interface_data.ip_addresses:
|
||||
normalized = self._normalize_ip_address(raw_ip)
|
||||
if normalized is None:
|
||||
continue
|
||||
|
||||
address, family = normalized
|
||||
ip_obj = self._sync_ip_address(address, vm_interface, result)
|
||||
if ip_obj is None:
|
||||
continue
|
||||
|
||||
if family == 4 and primary_ipv4 is None:
|
||||
primary_ipv4 = ip_obj
|
||||
if family == 6 and primary_ipv6 is None:
|
||||
primary_ipv6 = ip_obj
|
||||
|
||||
return primary_ipv4, primary_ipv6
|
||||
|
||||
def _normalize_ip_address(self, raw_ip):
|
||||
if not raw_ip:
|
||||
return None
|
||||
|
||||
raw_ip = str(raw_ip).split("%", 1)[0]
|
||||
|
||||
try:
|
||||
if "/" in raw_ip:
|
||||
ip_interface = ipaddress.ip_interface(raw_ip)
|
||||
ip_obj = ip_interface.ip
|
||||
prefix_length = ip_interface.network.prefixlen
|
||||
else:
|
||||
ip_obj = ipaddress.ip_address(raw_ip)
|
||||
prefix_length = (
|
||||
self.endpoint.default_ipv4_prefix_length
|
||||
if ip_obj.version == 4
|
||||
else self.endpoint.default_ipv6_prefix_length
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_unspecified or ip_obj.is_multicast:
|
||||
return None
|
||||
|
||||
return f"{ip_obj.compressed}/{prefix_length}", ip_obj.version
|
||||
|
||||
def _sync_ip_address(self, address, vm_interface, result):
|
||||
existing_ip = IPAddress.objects.filter(address=address).first()
|
||||
|
||||
if existing_ip and not self._may_update_ip(existing_ip, vm_interface):
|
||||
result.ip_conflicts += 1
|
||||
self.logger.warning(
|
||||
"Skipped IP %s because it belongs to another tenant or object.",
|
||||
address,
|
||||
)
|
||||
return None
|
||||
|
||||
created = existing_ip is None
|
||||
ip_obj = existing_ip or IPAddress(address=address)
|
||||
ip_obj.status = "active"
|
||||
ip_obj.assigned_object = vm_interface
|
||||
|
||||
if hasattr(ip_obj, "tenant"):
|
||||
ip_obj.tenant = self.endpoint.tenant
|
||||
|
||||
ip_obj.full_clean()
|
||||
ip_obj.save()
|
||||
|
||||
if created:
|
||||
result.ip_addresses_created += 1
|
||||
self.logger.info("Created IP %s", address)
|
||||
else:
|
||||
result.ip_addresses_updated += 1
|
||||
self.logger.info("Updated IP %s", address)
|
||||
|
||||
return ip_obj
|
||||
|
||||
def _may_update_ip(self, ip_obj, vm_interface):
|
||||
assigned_to_this_interface = (
|
||||
ip_obj.assigned_object_type_id == self.interface_content_type.id
|
||||
and ip_obj.assigned_object_id == vm_interface.pk
|
||||
)
|
||||
|
||||
if assigned_to_this_interface:
|
||||
return True
|
||||
|
||||
ip_tenant_id = getattr(ip_obj, "tenant_id", None)
|
||||
if self.endpoint.tenant_id and ip_tenant_id and ip_tenant_id != self.endpoint.tenant_id:
|
||||
return False
|
||||
|
||||
if ip_obj.assigned_object_id and not assigned_to_this_interface:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _set_primary_ips(self, nb_vm, primary_ipv4, primary_ipv6):
|
||||
if not self.endpoint.sync_primary_ips:
|
||||
return
|
||||
|
||||
update_fields = []
|
||||
if primary_ipv4 is not None:
|
||||
nb_vm.primary_ip4 = primary_ipv4
|
||||
update_fields.append("primary_ip4")
|
||||
if primary_ipv6 is not None:
|
||||
nb_vm.primary_ip6 = primary_ipv6
|
||||
update_fields.append("primary_ip6")
|
||||
|
||||
if update_fields:
|
||||
nb_vm.full_clean()
|
||||
nb_vm.save(update_fields=update_fields)
|
||||
self.logger.info("Updated primary IPs for %s", nb_vm.name)
|
||||
@@ -0,0 +1,53 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from netbox.tables import NetBoxTable
|
||||
from netbox.tables.columns import BooleanColumn, ChoiceFieldColumn
|
||||
|
||||
from .models import VCenterEndpoint
|
||||
|
||||
|
||||
class VCenterEndpointTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
)
|
||||
enabled = BooleanColumn()
|
||||
tenant = tables.Column(
|
||||
linkify=True,
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True,
|
||||
)
|
||||
cluster = tables.Column(
|
||||
linkify=True,
|
||||
)
|
||||
last_status = ChoiceFieldColumn(
|
||||
verbose_name=_("Last status"),
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VCenterEndpoint
|
||||
fields = (
|
||||
"pk",
|
||||
"id",
|
||||
"name",
|
||||
"enabled",
|
||||
"host",
|
||||
"tenant",
|
||||
"site",
|
||||
"cluster",
|
||||
"sync_interval_minutes",
|
||||
"next_sync_at",
|
||||
"last_status",
|
||||
"last_sync_at",
|
||||
"last_success_at",
|
||||
)
|
||||
default_columns = (
|
||||
"pk",
|
||||
"name",
|
||||
"enabled",
|
||||
"host",
|
||||
"tenant",
|
||||
"cluster",
|
||||
"last_status",
|
||||
"last_sync_at",
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">vCenter</h5>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Name</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Host</th>
|
||||
<td>{{ object.host }}:{{ object.port }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Username</th>
|
||||
<td>{{ object.username }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Enabled</th>
|
||||
<td>{{ object.enabled|yesno:"Yes,No" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Validate SSL</th>
|
||||
<td>{{ object.validate_ssl|yesno:"Yes,No" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">NetBox Target</h5>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Tenant</th>
|
||||
<td>{{ object.tenant|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Site</th>
|
||||
<td>{{ object.site|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Cluster</th>
|
||||
<td>{{ object.cluster }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Sync interval</th>
|
||||
<td>{{ object.sync_interval_minutes|default:"Manual only" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Next sync</th>
|
||||
<td>{{ object.next_sync_at|default:"-" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Sync</h5>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Last status</th>
|
||||
<td>{{ object.get_last_status_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Last sync</th>
|
||||
<td>{{ object.last_sync_at|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Last success</th>
|
||||
<td>{{ object.last_success_at|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">VMs</th>
|
||||
<td>{{ object.last_vm_count }} total, {{ object.last_created_count }} created, {{ object.last_updated_count }} updated, {{ object.last_error_count }} errors</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Message</th>
|
||||
<td>{{ object.last_message|default:"-" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if perms.netbox_vmware_importer.change_vcenterendpoint %}
|
||||
<div class="card-footer">
|
||||
<form method="post" action="{% url 'plugins:netbox_vmware_importer:vcenterendpoint_sync' pk=object.pk %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="mdi mdi-sync"></i> Sync jetzt starten
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Filters</h5>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Include regex</th>
|
||||
<td>{{ object.include_name_regex|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Exclude regex</th>
|
||||
<td>{{ object.exclude_name_regex|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Powered off VMs</th>
|
||||
<td>{{ object.sync_powered_off|yesno:"Synced,Skipped" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Interfaces/IPs</th>
|
||||
<td>{{ object.sync_interfaces|yesno:"Interfaces on,Interfaces off" }} / {{ object.sync_ip_addresses|yesno:"IPs on,IPs off" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if object.comments %}
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Comments</h5>
|
||||
<div class="card-body rendered-markdown">
|
||||
{{ object.comments|linebreaksbr }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = (
|
||||
path("vcenters/", views.VCenterEndpointListView.as_view(), name="vcenterendpoint_list"),
|
||||
path("vcenters/add/", views.VCenterEndpointEditView.as_view(), name="vcenterendpoint_add"),
|
||||
path("vcenters/<int:pk>/", views.VCenterEndpointView.as_view(), name="vcenterendpoint"),
|
||||
path("vcenters/<int:pk>/edit/", views.VCenterEndpointEditView.as_view(), name="vcenterendpoint_edit"),
|
||||
path("vcenters/<int:pk>/delete/", views.VCenterEndpointDeleteView.as_view(), name="vcenterendpoint_delete"),
|
||||
path("vcenters/<int:pk>/sync/", views.VCenterEndpointSyncView.as_view(), name="vcenterendpoint_sync"),
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from netbox.views import generic
|
||||
|
||||
from .filtersets import VCenterEndpointFilterSet
|
||||
from .forms import VCenterEndpointFilterForm, VCenterEndpointForm
|
||||
from .jobs import SyncVCenterEndpointJob
|
||||
from .models import VCenterEndpoint
|
||||
from .tables import VCenterEndpointTable
|
||||
|
||||
|
||||
class VCenterEndpointListView(generic.ObjectListView):
|
||||
queryset = VCenterEndpoint.objects.all()
|
||||
table = VCenterEndpointTable
|
||||
filterset = VCenterEndpointFilterSet
|
||||
filterset_form = VCenterEndpointFilterForm
|
||||
|
||||
|
||||
class VCenterEndpointView(generic.ObjectView):
|
||||
queryset = VCenterEndpoint.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
"latest_jobs": instance.get_latest_jobs()[:10],
|
||||
}
|
||||
|
||||
|
||||
class VCenterEndpointEditView(generic.ObjectEditView):
|
||||
queryset = VCenterEndpoint.objects.all()
|
||||
form = VCenterEndpointForm
|
||||
|
||||
|
||||
class VCenterEndpointDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VCenterEndpoint.objects.all()
|
||||
|
||||
|
||||
class VCenterEndpointSyncView(View):
|
||||
def post(self, request, pk):
|
||||
endpoint = get_object_or_404(VCenterEndpoint, pk=pk)
|
||||
|
||||
if not request.user.has_perm("netbox_vmware_importer.change_vcenterendpoint"):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
SyncVCenterEndpointJob.enqueue(instance=endpoint)
|
||||
endpoint.mark_queued()
|
||||
messages.success(request, _("VMware sync job queued for %(endpoint)s.") % {"endpoint": endpoint})
|
||||
|
||||
return redirect(endpoint)
|
||||
Reference in New Issue
Block a user