diff --git a/netbox_napalm_plugin/__init__.py b/netbox_napalm_plugin/__init__.py index 571dcd3..4a0b194 100644 --- a/netbox_napalm_plugin/__init__.py +++ b/netbox_napalm_plugin/__init__.py @@ -1,19 +1,19 @@ """Top-level package for NetBox Napalm Plugin.""" __author__ = """Arthur Hanson""" -__email__ = 'ahanson@netboxlabs.com' -__version__ = '0.1.0' +__email__ = "ahanson@netboxlabs.com" +__version__ = "0.1.0" from extras.plugins import PluginConfig class NapalmConfig(PluginConfig): - name = 'netbox_napalm_plugin' - verbose_name = 'NetBox Napalm Plugin' - description = 'NetBox plugin for Napalm.' - version = 'version' - base_url = 'netbox_napalm_plugin' + name = "netbox_napalm_plugin" + verbose_name = "NetBox Napalm Plugin" + description = "NetBox plugin for Napalm." + version = "version" + base_url = "netbox_napalm_plugin" config = NapalmConfig diff --git a/netbox_napalm_plugin/api/serializers.py b/netbox_napalm_plugin/api/serializers.py new file mode 100644 index 0000000..9a4728d --- /dev/null +++ b/netbox_napalm_plugin/api/serializers.py @@ -0,0 +1,28 @@ +from dcim.api.serializers import NestedPlatformSerializer +from netbox.api.serializers import NetBoxModelSerializer +from rest_framework import serializers + +from netbox_napalm_plugin.models import NapalmPlatform + + +class NapalmPlatformSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name="plugins-api:netbox_napalm_plugin:napalmplatform-detail" + ) + platform = NestedPlatformSerializer() + + class Meta: + model = NapalmPlatform + fields = [ + "id", + "platform", + "napalm_driver", + "napalm_args", + "tags", + "created", + "last_updated", + ] + + +class DeviceNAPALMSerializer(serializers.Serializer): + method = serializers.JSONField() diff --git a/netbox_napalm_plugin/api/urls.py b/netbox_napalm_plugin/api/urls.py new file mode 100644 index 0000000..ffb4171 --- /dev/null +++ b/netbox_napalm_plugin/api/urls.py @@ -0,0 +1,8 @@ +# api/urls.py +from netbox.api.routers import NetBoxRouter + +from .views import NapalmPlatformViewSet + +router = NetBoxRouter() +router.register("napalmplatform", NapalmPlatformViewSet) +urlpatterns = router.urls diff --git a/netbox_napalm_plugin/api/views.py b/netbox_napalm_plugin/api/views.py new file mode 100644 index 0000000..c44810a --- /dev/null +++ b/netbox_napalm_plugin/api/views.py @@ -0,0 +1,147 @@ +from dcim.models import Device +from django.shortcuts import get_object_or_404, redirect, render +from netbox.api.pagination import StripCountAnnotationsPaginator +from netbox.api.viewsets import NetBoxModelViewSet +from netbox.config import get_config +from rest_framework.decorators import action +from rest_framework.response import Response + +from netbox_napalm_plugin import filtersets +from netbox_napalm_plugin.models import NapalmPlatform + +from . import serializers + + +class NapalmPlatformViewSet(NetBoxModelViewSet): + queryset = NapalmPlatform.objects.prefetch_related( + "platform", + "tags", + ) + serializer_class = serializers.NapalmPlatformSerializer + filterset_class = filtersets.NapalmPlatformFilterSet + pagination_class = StripCountAnnotationsPaginator + + @action(detail=True, url_path="napalm") + def napalm(self, request, pk): + """ + Execute a NAPALM method on a Device + """ + device = get_object_or_404(Device.objects.all(), pk=pk) + if not device.primary_ip: + raise ServiceUnavailable( + "This device does not have a primary IP address configured." + ) + if device.platform is None: + raise ServiceUnavailable("No platform is configured for this device.") + if ( + not hasattr(device.platform, "napalm") + or not device.platform.napalm.napalm_driver + ): + raise ServiceUnavailable( + f"No NAPALM driver is configured for this device's platform: {device.platform}." + ) + + # Check for primary IP address from NetBox object + if device.primary_ip: + host = str(device.primary_ip.address.ip) + else: + # Raise exception for no IP address and no Name if device.name does not exist + if not device.name: + raise ServiceUnavailable( + "This device does not have a primary IP address or device name to lookup configured." + ) + try: + # Attempt to complete a DNS name resolution if no primary_ip is set + host = socket.gethostbyname(device.name) + except socket.gaierror: + # Name lookup failure + raise ServiceUnavailable( + f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or " + f"setup name resolution." + ) + + # Check that NAPALM is installed + try: + import napalm + from napalm.base.exceptions import ModuleImportError + except ModuleNotFoundError as e: + if getattr(e, "name") == "napalm": + raise ServiceUnavailable( + "NAPALM is not installed. Please see the documentation for instructions." + ) + raise e + + # Validate the configured driver + try: + driver = napalm.get_network_driver(device.platform.napalm_driver) + except ModuleImportError: + raise ServiceUnavailable( + "NAPALM driver for platform {} not found: {}.".format( + device.platform, device.platform.napalm_driver + ) + ) + + # Verify user permission + if not request.user.has_perm("dcim.napalm_read_device"): + return HttpResponseForbidden() + + napalm_methods = request.GET.getlist("method") + response = {m: None for m in napalm_methods} + + config = get_config() + username = config.NAPALM_USERNAME + password = config.NAPALM_PASSWORD + timeout = config.NAPALM_TIMEOUT + optional_args = config.NAPALM_ARGS.copy() + if device.platform.napalm_args is not None: + optional_args.update(device.platform.napalm_args) + + # Update NAPALM parameters according to the request headers + for header in request.headers: + if header[:9].lower() != "x-napalm-": + continue + + key = header[9:] + if key.lower() == "username": + username = request.headers[header] + elif key.lower() == "password": + password = request.headers[header] + elif key: + optional_args[key.lower()] = request.headers[header] + + # Connect to the device + d = driver( + hostname=host, + username=username, + password=password, + timeout=timeout, + optional_args=optional_args, + ) + try: + d.open() + except Exception as e: + raise ServiceUnavailable( + "Error connecting to the device at {}: {}".format(host, e) + ) + + # Validate and execute each specified NAPALM method + for method in napalm_methods: + if not hasattr(driver, method): + response[method] = {"error": "Unknown NAPALM method"} + continue + if not method.startswith("get_"): + response[method] = {"error": "Only get_* NAPALM methods are supported"} + continue + try: + response[method] = getattr(d, method)() + except NotImplementedError: + response[method] = { + "error": "Method {} not implemented for NAPALM driver {}".format( + method, driver + ) + } + except Exception as e: + response[method] = {"error": "Method {} failed: {}".format(method, e)} + d.close() + + return Response(response) diff --git a/netbox_napalm_plugin/filtersets.py b/netbox_napalm_plugin/filtersets.py index 0fa1c06..7d9dcc7 100644 --- a/netbox_napalm_plugin/filtersets.py +++ b/netbox_napalm_plugin/filtersets.py @@ -1,12 +1,25 @@ -from netbox.filtersets import NetBoxModelFilterSet +import django_filters +from dcim.models import Platform +from django.utils.translation import gettext as _ +from netbox.filtersets import (NetBoxModelFilterSet, + OrganizationalModelFilterSet) + from .models import NapalmPlatform -# class NapalmFilterSet(NetBoxModelFilterSet): -# -# class Meta: -# model = Napalm -# fields = ['name', ] -# -# def search(self, queryset, name, value): -# return queryset.filter(description__icontains=value) +class NapalmPlatformFilterSet(NetBoxModelFilterSet): + platform_id = django_filters.ModelMultipleChoiceFilter( + field_name="platform", + queryset=Platform.objects.all(), + label=_("Platform (ID)"), + ) + platform = django_filters.ModelMultipleChoiceFilter( + field_name="platform__slug", + queryset=Platform.objects.all(), + to_field_name="slug", + label=_("Platform (slug)"), + ) + + class Meta: + model = NapalmPlatform + fields = ["id", "napalm_driver"] diff --git a/netbox_napalm_plugin/forms.py b/netbox_napalm_plugin/forms.py index 86f259e..62aa920 100644 --- a/netbox_napalm_plugin/forms.py +++ b/netbox_napalm_plugin/forms.py @@ -1,13 +1,17 @@ from django import forms - from ipam.models import Prefix -from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm +from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import CommentField, DynamicModelChoiceField + from .models import NapalmPlatform class NapalmPlatformForm(NetBoxModelForm): - class Meta: model = NapalmPlatform - fields = ('tags', ) + fields = ( + "platform", + "napalm_driver", + "napalm_args", + "tags", + ) diff --git a/netbox_napalm_plugin/migrations/0001_initial.py b/netbox_napalm_plugin/migrations/0001_initial.py index 4e04aba..40abe32 100644 --- a/netbox_napalm_plugin/migrations/0001_initial.py +++ b/netbox_napalm_plugin/migrations/0001_initial.py @@ -1,9 +1,9 @@ # Generated by Django 4.1.5 on 2023-02-15 16:57 -from django.db import migrations, models import django.db.models.deletion import taggit.managers import utilities.json +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/netbox_napalm_plugin/migrations/0002_auto_20230215_1752.py b/netbox_napalm_plugin/migrations/0002_auto_20230215_1752.py index e67a52e..7f8ac0e 100644 --- a/netbox_napalm_plugin/migrations/0002_auto_20230215_1752.py +++ b/netbox_napalm_plugin/migrations/0002_auto_20230215_1752.py @@ -2,10 +2,11 @@ from django.db import migrations + def migrate_napalm(apps, schema_editor): - Platform = apps.get_model('dcim', 'Platform') - NapalmPlatform = apps.get_model('netbox_napalm_plugin', 'NapalmPlatform') - qs = Platform.objects.all().exclude(napalm_driver__exact='') + Platform = apps.get_model("dcim", "Platform") + NapalmPlatform = apps.get_model("netbox_napalm_plugin", "NapalmPlatform") + qs = Platform.objects.all().exclude(napalm_driver__exact="") for platform in qs: NapalmPlatform.objects.create( platform=platform, @@ -13,6 +14,7 @@ def migrate_napalm(apps, schema_editor): napalm_args=platform.napalm_args, ) + class Migration(migrations.Migration): dependencies = [ ("netbox_napalm_plugin", "0001_initial"), diff --git a/netbox_napalm_plugin/models.py b/netbox_napalm_plugin/models.py index a3e75ac..1d93f1e 100644 --- a/netbox_napalm_plugin/models.py +++ b/netbox_napalm_plugin/models.py @@ -1,9 +1,8 @@ +from dcim.models.devices import Platform from django.db import models from django.urls import reverse from django.utils.translation import gettext as _ - from netbox.models import NetBoxModel -from dcim.models.devices import Platform class NapalmPlatform(NetBoxModel): @@ -15,21 +14,25 @@ class NapalmPlatform(NetBoxModel): napalm_driver = models.CharField( max_length=50, blank=True, - verbose_name='NAPALM driver', - help_text=_('The name of the NAPALM driver to use when interacting with devices') + verbose_name="NAPALM driver", + help_text=_( + "The name of the NAPALM driver to use when interacting with devices" + ), ) napalm_args = models.JSONField( blank=True, null=True, - verbose_name='NAPALM arguments', - help_text=_('Additional arguments to pass when initiating the NAPALM driver (JSON format)') + verbose_name="NAPALM arguments", + help_text=_( + "Additional arguments to pass when initiating the NAPALM driver (JSON format)" + ), ) class Meta: - ordering = ('pk',) + ordering = ("pk",) def __str__(self): return f"{self.platform.name} -> {self.napalm_driver}" def get_absolute_url(self): - return reverse('plugins:netbox_napalm_plugin:napalm', args=[self.pk]) + return reverse("plugins:netbox_napalm_plugin:napalm", args=[self.pk]) diff --git a/netbox_napalm_plugin/navigation.py b/netbox_napalm_plugin/navigation.py index b86020e..bc7aed3 100644 --- a/netbox_napalm_plugin/navigation.py +++ b/netbox_napalm_plugin/navigation.py @@ -1,20 +1,19 @@ from extras.plugins import PluginMenuButton, PluginMenuItem from utilities.choices import ButtonColorChoices - plugin_buttons = [ PluginMenuButton( - link='plugins:netbox_napalm_plugin:napalmplatform_add', - title='Add', - icon_class='mdi mdi-plus-thick', - color=ButtonColorChoices.GREEN + link="plugins:netbox_napalm_plugin:napalmplatform_add", + title="Add", + icon_class="mdi mdi-plus-thick", + color=ButtonColorChoices.GREEN, ) ] menu_items = ( PluginMenuItem( - link='plugins:netbox_napalm_plugin:napalmplatform_list', - link_text='Napalm', - buttons=plugin_buttons + link="plugins:netbox_napalm_plugin:napalmplatform_list", + link_text="Napalm", + buttons=plugin_buttons, ), ) diff --git a/netbox_napalm_plugin/tables.py b/netbox_napalm_plugin/tables.py index c45d3ed..e79687e 100644 --- a/netbox_napalm_plugin/tables.py +++ b/netbox_napalm_plugin/tables.py @@ -1,12 +1,11 @@ import django_tables2 as tables +from netbox.tables import ChoiceFieldColumn, NetBoxTable -from netbox.tables import NetBoxTable, ChoiceFieldColumn from .models import NapalmPlatform class NapalmPlatformTable(NetBoxTable): - class Meta(NetBoxTable.Meta): model = NapalmPlatform - fields = ('pk', 'platform__name', 'napalm_driver', 'napalm_args', 'actions') - default_columns = ('platform__name', 'napalm_driver') + fields = ("pk", "platform__name", "napalm_driver", "napalm_args", "actions") + default_columns = ("platform__name", "napalm_driver") diff --git a/netbox_napalm_plugin/templates/netbox_napalm_plugin/config.html b/netbox_napalm_plugin/templates/netbox_napalm_plugin/config.html index 3daf80b..525c1e1 100644 --- a/netbox_napalm_plugin/templates/netbox_napalm_plugin/config.html +++ b/netbox_napalm_plugin/templates/netbox_napalm_plugin/config.html @@ -41,5 +41,5 @@
Device Configuration
{% endblock %} {% block data %} - + {% endblock %} diff --git a/netbox_napalm_plugin/templates/netbox_napalm_plugin/lldp_neighbors.html b/netbox_napalm_plugin/templates/netbox_napalm_plugin/lldp_neighbors.html index f1bc18b..917a7f0 100644 --- a/netbox_napalm_plugin/templates/netbox_napalm_plugin/lldp_neighbors.html +++ b/netbox_napalm_plugin/templates/netbox_napalm_plugin/lldp_neighbors.html @@ -62,5 +62,5 @@
LLDP Neighbors
{% endblock %} {% block data %} - + {% endblock %} diff --git a/netbox_napalm_plugin/templates/netbox_napalm_plugin/status.html b/netbox_napalm_plugin/templates/netbox_napalm_plugin/status.html index c347e00..a9e9435 100644 --- a/netbox_napalm_plugin/templates/netbox_napalm_plugin/status.html +++ b/netbox_napalm_plugin/templates/netbox_napalm_plugin/status.html @@ -89,5 +89,5 @@
Environment
{% endblock %} {% block data %} - + {% endblock %} diff --git a/netbox_napalm_plugin/urls.py b/netbox_napalm_plugin/urls.py index 7adb14d..3aad75b 100644 --- a/netbox_napalm_plugin/urls.py +++ b/netbox_napalm_plugin/urls.py @@ -1,18 +1,28 @@ from django.urls import path - from netbox.views.generic import ObjectChangeLogView -from . import models, views +from . import models, views urlpatterns = ( - - path('napal/', views.NapalmPlatformListView.as_view(), name='napalmplatform_list'), - path('napalm/add/', views.NapalmPlatformEditView.as_view(), name='napalmplatform_add'), - path('napalm//', views.NapalmPlatformView.as_view(), name='napalmplatform'), - path('napalm//edit/', views.NapalmPlatformEditView.as_view(), name='napalmplatform_edit'), - path('napalm//delete/', views.NapalmPlatformDeleteView.as_view(), name='napalmplatform_delete'), - path('napalm//changelog/', ObjectChangeLogView.as_view(), name='napalmplatform_changelog', kwargs={ - 'model': models.NapalmPlatform - }), - + path("napalm/", views.NapalmPlatformListView.as_view(), name="napalmplatform_list"), + path( + "napalm/add/", views.NapalmPlatformEditView.as_view(), name="napalmplatform_add" + ), + path("napalm//", views.NapalmPlatformView.as_view(), name="napalmplatform"), + path( + "napalm//edit/", + views.NapalmPlatformEditView.as_view(), + name="napalmplatform_edit", + ), + path( + "napalm//delete/", + views.NapalmPlatformDeleteView.as_view(), + name="napalmplatform_delete", + ), + path( + "napalm//changelog/", + ObjectChangeLogView.as_view(), + name="napalmplatform_changelog", + kwargs={"model": models.NapalmPlatform}, + ), ) diff --git a/netbox_napalm_plugin/views.py b/netbox_napalm_plugin/views.py index 33969cc..f40ef06 100644 --- a/netbox_napalm_plugin/views.py +++ b/netbox_napalm_plugin/views.py @@ -1,11 +1,10 @@ -from django.db.models import Count -from django.utils.translation import gettext as _ - from dcim.constants import NONCONNECTABLE_IFACE_TYPES from dcim.models import Device - +from django.db.models import Count +from django.utils.translation import gettext as _ from netbox.views import generic from utilities.views import ViewTab, register_model_view + from . import filtersets, forms, models, tables @@ -28,62 +27,56 @@ class NapalmPlatformDeleteView(generic.ObjectDeleteView): class NAPALMViewTab(ViewTab): - def render(self, instance): # Display NAPALM tabs only for devices which meet certain requirements if not ( - hasattr(instance.platform, "napalm") and - instance.status == 'active' and - instance.primary_ip and - instance.platform and - instance.platform.napalm.napalm_driver + hasattr(instance.platform, "napalm") + and instance.status == "active" + and instance.primary_ip + and instance.platform + and instance.platform.napalm.napalm_driver ): return None return super().render(instance) -@register_model_view(Device, 'status') +@register_model_view(Device, "status") class DeviceStatusView(generic.ObjectView): - additional_permissions = ['dcim.napalm_read_device'] + additional_permissions = ["dcim.napalm_read_device"] queryset = Device.objects.all() - template_name = 'netbox_napalm_plugin/status.html' + template_name = "netbox_napalm_plugin/status.html" tab = NAPALMViewTab( - label=_('Status'), - permission='dcim.napalm_read_device', - weight=3000 + label=_("Status"), permission="dcim.napalm_read_device", weight=3000 ) -@register_model_view(Device, 'lldp_neighbors', path='lldp-neighbors') +@register_model_view(Device, "lldp_neighbors", path="lldp-neighbors") class DeviceLLDPNeighborsView(generic.ObjectView): - additional_permissions = ['dcim.napalm_read_device'] + additional_permissions = ["dcim.napalm_read_device"] queryset = Device.objects.all() - template_name = 'netbox_napalm_plugin/lldp_neighbors.html' + template_name = "netbox_napalm_plugin/lldp_neighbors.html" tab = NAPALMViewTab( - label=_('LLDP Neighbors'), - permission='dcim.napalm_read_device', - weight=3100 + label=_("LLDP Neighbors"), permission="dcim.napalm_read_device", weight=3100 ) def get_extra_context(self, request, instance): - interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( - '_path' - ).exclude( - type__in=NONCONNECTABLE_IFACE_TYPES + interfaces = ( + instance.vc_interfaces() + .restrict(request.user, "view") + .prefetch_related("_path") + .exclude(type__in=NONCONNECTABLE_IFACE_TYPES) ) return { - 'interfaces': interfaces, + "interfaces": interfaces, } -@register_model_view(Device, 'config') +@register_model_view(Device, "config") class DeviceConfigView(generic.ObjectView): - additional_permissions = ['dcim.napalm_read_device'] + additional_permissions = ["dcim.napalm_read_device"] queryset = Device.objects.all() - template_name = 'netbox_napalm_plugin/config.html' + template_name = "netbox_napalm_plugin/config.html" tab = NAPALMViewTab( - label=_('Config'), - permission='dcim.napalm_read_device', - weight=3200 + label=_("Config"), permission="dcim.napalm_read_device", weight=3200 ) diff --git a/setup.py b/setup.py index 24d2307..4dbd616 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open('README.md') as readme_file: readme = readme_file.read() -requirements = [] +requirements = ['napalm<5.0'] setup( author="Arthur Hanson",