commit d0b7fb5c44b1f2e381f7884204f59186f9a0377b Author: Victor Golovanenko Date: Tue Apr 20 07:22:26 2021 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12ad828 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e41479f --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# netbox-interface-sync +[Русская версия](./README_ru.md) +## Overview +This plugin allows you to compare and synchronize interfaces between devices and device types in NetBox. It can be useful for finding and correcting inconsistencies between interfaces. +Tested with NetBox versions 2.10, 2.11 +## Installation +If your NetBox installation uses virtualenv, activate it like this: +``` +source /opt/netbox/venv/bin/activate +``` +Clone this repository, then go to the folder with it and install the plugin: +``` +pip install . +``` +To enable to plugin, add the plugin's name to the `PLUGINS` list in `configuration.py` (it's usually located in `/opt/netbox/netbox/netbox/`) like so: +``` +PLUGINS = [ + 'netbox_interface_sync' +] +``` +Don't forget to restart NetBox: +``` +sudo systemctl restart netbox +``` +## Usage +To compare the interfaces, open the page of the desired device and find the "Compare device interfaces with device type interfaces" button": +![Device page](docs/images/1_device_page.png) +Mark the required actions with the checkboxes and click "Apply". +![Interface comparison](docs/images/2_interface_comparison.png) +### Plugin settings +If you want to override the default values, configure the `PLUGINS_CONFIG` in your `configuration.py`: +``` +PLUGINS_CONFIG = { + 'netbox_interface_sync': { + 'exclude_virtual_interfaces': True + } +} +``` +| Setting | Default value | Description | +| --- | --- | --- | +| exclude_virtual_interfaces | `True` | Exclude virtual interfaces (VLANs, LAGs) from comparison diff --git a/README_ru.md b/README_ru.md new file mode 100644 index 0000000..694a717 --- /dev/null +++ b/README_ru.md @@ -0,0 +1,40 @@ +# netbox-interface-sync +[English version](./README.md) +## Обзор +Плагин для NetBox, позволяющий сравнивать и синхронизировать интерфейсы между устройствами (devices) и типами устройств (device types). Полезен для поиска и исправления несоответствий между интерфейсами. Работа проверена с NetBox версий 2.10, 2.11 +## Установка +Если NetBox использует virtualenv, то активируйте его, например, так: +``` +source /opt/netbox/venv/bin/activate +``` +Склонируйте этот репозиторий, затем перейдите в папку с ним и установите плагин: +``` +pip install . +``` +Включите плагин в файле `configuration.py` (обычно он находится в `/opt/netbox/netbox/netbox/`), добавьте его имя в список `PLUGINS`: +``` +PLUGINS = [ + 'netbox_interface_sync' +] +``` +Перезапустите NetBox: +``` +sudo systemctl restart netbox +``` +## Использование +Для того чтобы сравнить интерфейсы, откройте страницу нужного устройства и найдите кнопку "Compare device interfaces with device type interfaces" справа сверху: +![Device page](docs/images/1_device_page.png) +Отметьте требуемые действия напротив интерфейсов флажками и нажмите "Apply". +![Interface comparison](docs/images/2_interface_comparison.png) +### Настройки плагина +Если вы хотите переопределить значения по умолчанию, настройте переменную `PLUGINS_CONFIG` в вашем файле `configuration.py`: +``` +PLUGINS_CONFIG = { + 'netbox_interface_sync': { + 'exclude_virtual_interfaces': True + } +} +``` +| Настройка | Значение по умолчанию | Описание | +| --- | --- | --- | +| exclude_virtual_interfaces | `True` | Не учитывать виртуальные интерфейсы (VLAN, LAG) при сравнении diff --git a/docs/images/1_device_page.png b/docs/images/1_device_page.png new file mode 100644 index 0000000..b68f1a5 Binary files /dev/null and b/docs/images/1_device_page.png differ diff --git a/docs/images/2_interface_comparison.png b/docs/images/2_interface_comparison.png new file mode 100644 index 0000000..d69170b Binary files /dev/null and b/docs/images/2_interface_comparison.png differ diff --git a/netbox_interface_sync/__init__.py b/netbox_interface_sync/__init__.py new file mode 100644 index 0000000..d655c9e --- /dev/null +++ b/netbox_interface_sync/__init__.py @@ -0,0 +1,16 @@ +from extras.plugins import PluginConfig + + +class Config(PluginConfig): + name = 'netbox_interface_sync' + verbose_name = 'NetBox interface synchronization' + description = 'Syncing interfaces with the interfaces from device type for NetBox devices' + version = '0.1' + author = 'Victor Golovanenko' + author_email = 'drygdryg2014@yandex.ru' + default_settings = { + 'exclude_virtual_interfaces': True + } + + +config = Config diff --git a/netbox_interface_sync/forms.py b/netbox_interface_sync/forms.py new file mode 100644 index 0000000..fcdf5a5 --- /dev/null +++ b/netbox_interface_sync/forms.py @@ -0,0 +1,6 @@ +from django import forms + + +class InterfaceComparisonForm(forms.Form): + add_to_device = forms.BooleanField(required=False) + remove_from_device = forms.BooleanField(required=False) diff --git a/netbox_interface_sync/template_content.py b/netbox_interface_sync/template_content.py new file mode 100644 index 0000000..be5c5a4 --- /dev/null +++ b/netbox_interface_sync/template_content.py @@ -0,0 +1,29 @@ +from extras.plugins import PluginTemplateExtension +from dcim.models import Interface, InterfaceTemplate + + +class DeviceViewExtension(PluginTemplateExtension): + model = "dcim.device" + + def buttons(self): + """Implements a compare interfaces button at the top of the page""" + obj = self.context['object'] + return self.render("netbox_interface_sync/compare_interfaces_button.html", extra_context={ + "device": obj + }) + + def right_page(self): + """Implements a panel with the number of interfaces on the right side of the page""" + obj = self.context['object'] + interfaces = Interface.objects.filter(device=obj) + real_interfaces = interfaces.exclude(type__in=["virtual", "lag"]) + interface_templates = InterfaceTemplate.objects.filter(device_type=obj.device_type) + + return self.render("netbox_interface_sync/number_of_interfaces_panel.html", extra_context={ + "interfaces": interfaces, + "real_interfaces": real_interfaces, + "interface_templates": interface_templates + }) + + +template_extensions = [DeviceViewExtension] diff --git a/netbox_interface_sync/templates/netbox_interface_sync/compare_interfaces_button.html b/netbox_interface_sync/templates/netbox_interface_sync/compare_interfaces_button.html new file mode 100644 index 0000000..d21b733 --- /dev/null +++ b/netbox_interface_sync/templates/netbox_interface_sync/compare_interfaces_button.html @@ -0,0 +1,3 @@ + + Compare device interfaces with device type interfaces + \ No newline at end of file diff --git a/netbox_interface_sync/templates/netbox_interface_sync/interface_comparison.html b/netbox_interface_sync/templates/netbox_interface_sync/interface_comparison.html new file mode 100644 index 0000000..bf74811 --- /dev/null +++ b/netbox_interface_sync/templates/netbox_interface_sync/interface_comparison.html @@ -0,0 +1,153 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}{{ device }} - Interface comparison{% endblock %}

+{% endblock %} + +{% block content %} + + + +

+{% if templates_count == interfaces_count %} + The device and device type have the same number of interfaces. +{% else %} + The device and device type have different number of interfaces.
+ Device: {{ interfaces_count }}
+ Device type: {{ templates_count }} +{% endif %} +

+ +
+ + {% csrf_token %} + + + + + + + + + + + {% for template, interface in comparison_items %} + {% if template %} + + + + + + {% else %} + + + + + + {% endif %} + {% endfor %} +
Device typeActions
NameType + +
+ {% if interface and template.name != interface.name %} + {{ template.name }} + {% else %} + {{ template.name }} + {% endif %} + {{ template.type }} + {% if not interface %} + + {% endif %} +
   
+ + + + + + + + + + + + + + {% for template, interface in comparison_items %} + {% if interface %} + + + + + + + {% else %} + + + + + + + {% endif %} + {% endfor %} +
DeviceActions
NameType + + + +
+ {% if template and template.name != interface.name %} + {{ interface.name }} + {% else %} + {{ interface.name }} + {% endif %} + {{ interface.type }} + {% if not template %} + + {% endif %} + + {% if template and template.name != interface.name %} + + {% endif %} +
    
+
+ +
+
+ +{% endblock %} diff --git a/netbox_interface_sync/templates/netbox_interface_sync/number_of_interfaces_panel.html b/netbox_interface_sync/templates/netbox_interface_sync/number_of_interfaces_panel.html new file mode 100644 index 0000000..e2c11f8 --- /dev/null +++ b/netbox_interface_sync/templates/netbox_interface_sync/number_of_interfaces_panel.html @@ -0,0 +1,12 @@ +
+
+ Number of interfaces +
+
+ Total interfaces: {{ interfaces|length }}
+ {% if config.exclude_virtual_interfaces %} + Non-virtual interfaces: {{ real_interfaces|length }}
+ {% endif %} + Interfaces in the corresponding device type: {{ interface_templates|length }} +
+
\ No newline at end of file diff --git a/netbox_interface_sync/urls.py b/netbox_interface_sync/urls.py new file mode 100644 index 0000000..cef81f2 --- /dev/null +++ b/netbox_interface_sync/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + + +# Define a list of URL patterns to be imported by NetBox. Each pattern maps a URL to +# a specific view so that it can be accessed by users. +urlpatterns = ( + path("interface-comparison//", views.InterfaceComparisonView.as_view(), name="interface_comparison"), +) diff --git a/netbox_interface_sync/utils.py b/netbox_interface_sync/utils.py new file mode 100644 index 0000000..bfbd110 --- /dev/null +++ b/netbox_interface_sync/utils.py @@ -0,0 +1,34 @@ +import re +from typing import Iterable +from dataclasses import dataclass + + +def split(s): + for x, y in re.findall(r'(\d*)(\D*)', s): + yield '', int(x or '0') + yield y, 0 + + +def natural_keys(c): + return tuple(split(c)) + + +def human_sorted(iterable: Iterable): + return sorted(iterable, key=natural_keys) + + +@dataclass(frozen=True) +class UnifiedInterface: + """A unified way to represent the interface and interface template""" + id: int + name: str + type: str + is_template: bool = False + + def __eq__(self, other): + # Ignore some fields when comparing; ignore interface name case and whitespaces + return (self.name.lower().replace(' ', '') == other.name.lower().replace(' ', '')) and (self.type == other.type) + + def __hash__(self): + # Ignore some fields when hashing; ignore interface name case and whitespaces + return hash((self.name.lower().replace(' ', ''), self.type)) diff --git a/netbox_interface_sync/views.py b/netbox_interface_sync/views.py new file mode 100644 index 0000000..d4537c4 --- /dev/null +++ b/netbox_interface_sync/views.py @@ -0,0 +1,112 @@ +from django.shortcuts import get_object_or_404, render, redirect +from django.views.generic import View +from dcim.models import Device, Interface, InterfaceTemplate +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.conf import settings +from django.contrib import messages + +from .utils import UnifiedInterface, natural_keys +from .forms import InterfaceComparisonForm + +config = settings.PLUGINS_CONFIG['netbox_interface_sync'] + + +class InterfaceComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View): + """Comparison of interfaces between a device and a device type and beautiful visualization""" + permission_required = ("dcim.view_interface", "dcim.add_interface", "dcim.change_interface", "dcim.delete_interface") + + def get(self, request, device_id): + device = get_object_or_404(Device.objects.filter(id=device_id)) + interfaces = Interface.objects.filter(device=device) + if config["exclude_virtual_interfaces"]: + interfaces = list(filter(lambda i: not i.is_virtual, interfaces)) + interface_templates = InterfaceTemplate.objects.filter(device_type=device.device_type) + + unified_interfaces = [UnifiedInterface(i.id, i.name, i.type) for i in interfaces] + unified_interface_templates = [UnifiedInterface(i.id, i.name, i.type, is_template=True) for i in interface_templates] + + # List of interfaces and interface templates presented in the unified format + overall_interfaces = list(set(unified_interface_templates + unified_interfaces)) + overall_interfaces.sort(key=lambda o: natural_keys(o.name)) + + comparison_templates = [] + comparison_interfaces = [] + for i in overall_interfaces: + try: + comparison_templates.append(unified_interface_templates[unified_interface_templates.index(i)]) + except ValueError: + comparison_templates.append(None) + + try: + comparison_interfaces.append(unified_interfaces[unified_interfaces.index(i)]) + except ValueError: + comparison_interfaces.append(None) + + comparison_items = list(zip(comparison_templates, comparison_interfaces)) + return render( + request, "netbox_interface_sync/interface_comparison.html", + { + "comparison_items": comparison_items, + "templates_count": len(interface_templates), + "interfaces_count": len(interfaces), + "device": device + } + ) + + def post(self, request, device_id): + form = InterfaceComparisonForm(request.POST) + if form.is_valid(): + device = get_object_or_404(Device.objects.filter(id=device_id)) + interfaces = Interface.objects.filter(device=device) + if config["exclude_virtual_interfaces"]: + interfaces = interfaces.exclude(type__in=["virtual", "lag"]) + interface_templates = InterfaceTemplate.objects.filter(device_type=device.device_type) + + # Manually validating interfaces and interface templates lists + add_to_device = filter( + lambda i: i in interface_templates.values_list("id", flat=True), + map(int, filter(lambda x: x.isdigit(), request.POST.getlist("add_to_device"))) + ) + remove_from_device = filter( + lambda i: i in interfaces.values_list("id", flat=True), + map(int, filter(lambda x: x.isdigit(), request.POST.getlist("remove_from_device"))) + ) + + # Add selected interfaces to the device and count them + add_to_device_interfaces = InterfaceTemplate.objects.filter(id__in=add_to_device) + interfaces_created = len(Interface.objects.bulk_create([ + Interface(device=device, name=i.name, type=i.type) for i in add_to_device_interfaces + ])) + + # Remove selected interfaces from the device and count them + interfaces_deleted = Interface.objects.filter(id__in=remove_from_device).delete()[0] + + # Getting and validating a list of interfaces to rename + fix_name_interfaces = filter(lambda i: str(i.id) in request.POST.getlist("fix_name"), interfaces) + # Casting interface templates into UnifiedInterface objects for proper comparison with interfaces for renaming + unified_interface_templates = [UnifiedInterface(i.id, i.name, i.type) for i in interface_templates] + + # Rename selected interfaces + interfaces_fixed = 0 + for interface in fix_name_interfaces: + unified_interface = UnifiedInterface(interface.id, interface.name, interface.type) + try: + # Try to extract an interface template with the corresponding name + corresponding_template = unified_interface_templates[unified_interface_templates.index(unified_interface)] + interface.name = corresponding_template.name + interface.save() + interfaces_fixed += 1 + except ValueError: + pass + + # Generating result message + message = [] + if interfaces_created > 0: + message.append(f"created {interfaces_created} interfaces") + if interfaces_deleted > 0: + message.append(f"deleted {interfaces_deleted} interfaces") + if interfaces_fixed > 0: + message.append(f"fixed {interfaces_fixed} interfaces") + messages.success(request, "; ".join(message).capitalize()) + + return redirect(request.path) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..537209b --- /dev/null +++ b/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup + +setup( + name='netbox-interface-sync', + version='0.1', + description='Syncing interfaces with the interfaces from device type for NetBox devices', + author='Victor Golovanenko', + license='GPL-3.0', + install_requires=[], + packages=["netbox_interface_sync"], + package_data={"netbox_interface_sync": ["templates/netbox_interface_sync/*.html"]}, + zip_safe=False +)