diff --git a/.github/workflows/commit.yaml b/.github/workflows/commit.yaml index 4731536..0effea7 100644 --- a/.github/workflows/commit.yaml +++ b/.github/workflows/commit.yaml @@ -31,7 +31,7 @@ jobs: strategy: max-parallel: 10 matrix: - netbox_version: ["v3.5.9", "v3.6.4"] + netbox_version: ["v3.5.9", "v3.6.9"] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/README.md b/README.md index 2955f8b..0d7795c 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,19 @@ python manage.py collectstatic --noinput Restart NetBox service: ```bash -systemctl restart netbox +systemctl restart netbox netbox-rq ``` ## Usage -Read this [doc](docs/colliecting-diffs.md) about collecting diffs, for configuration management read [this](docs/configuratiom-management.md) +Read this [doc](https://miaow2.github.io/netbox-config-diff/colliecting-diffs/) about collecting diffs, for configuration management read [this](https://miaow2.github.io/netbox-config-diff/configuratiom-management/) + +## Video + +My presention about plugin at October NetBox community call (19.10.2023). + +[![October NetBox community call](https://img.youtube.com/vi/B4uhtYh278o/0.jpg)](https://youtu.be/B4uhtYh278o?t=425) ## Screenshots diff --git a/docs/changelog.md b/docs/changelog.md index 1766866..07af73b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,11 @@ # Changelog +## 2.2.0 (2024-02-06) + +* [#47](https://github.com/miaow2/netbox-config-diff/issues/47) Move plugin to separete menu item in navbar and add tab for devices with compliance result +* [#50](https://github.com/miaow2/netbox-config-diff/issues/50) Add template field for device name in DataSource to ConfigDiffScript +* [#53](https://github.com/miaow2/netbox-config-diff/issues/53) Add netbox-rq to installation process docs + ## 2.1.0 (2023-10-26) * [#35](https://github.com/miaow2/netbox-config-diff/issues/35) Add ability to define password for accessing priviliged exec mode diff --git a/docs/colliecting-diffs.md b/docs/colliecting-diffs.md index 54ddae6..b5cd95f 100644 --- a/docs/colliecting-diffs.md +++ b/docs/colliecting-diffs.md @@ -1,7 +1,7 @@ # Usage -Under `Plugins` navbar menu you can find plugin +In navbar serach for `Config Diff Plugin` menu ![Screenshot of navbar](media/screenshots/navbar.png) @@ -42,6 +42,17 @@ If you have configs in NetBox DataSource, you can define it, the script instead !!! note Only synced DataSources are acceptable +If in your DataSource config names are different from the hostnames of the devices, you can specify config name with Jinja2 template in `Name template` field. + Reference device with `{{ object }}` variable. + +For example, config name is virtual chassis name plus `config` (`switchname-config`) and your devices names are `switchname1`, `switchname2` and etc. + +You can define Jinja2 template with logic to use virtual chassis name if device is in chassis, else use device name: + +``` +{% if object.virtual_chassis %}{{ object.virtual_chassis.name }}-config{% else %}{{ object.name }}{% endif %} +``` + ![Screenshot of the script](media/screenshots/script.png) ## Results diff --git a/docs/media/screenshots/navbar.png b/docs/media/screenshots/navbar.png index 167889f..7b0dba2 100644 Binary files a/docs/media/screenshots/navbar.png and b/docs/media/screenshots/navbar.png differ diff --git a/docs/media/screenshots/script.png b/docs/media/screenshots/script.png index b0fe676..498aeb6 100644 Binary files a/docs/media/screenshots/script.png and b/docs/media/screenshots/script.png differ diff --git a/netbox_config_diff/__init__.py b/netbox_config_diff/__init__.py index 3cd5c9e..f687423 100644 --- a/netbox_config_diff/__init__.py +++ b/netbox_config_diff/__init__.py @@ -2,7 +2,7 @@ __author__ = "Artem Kotik" __email__ = "miaow2@yandex.ru" -__version__ = "2.1.0" +__version__ = "2.2.0" class ConfigDiffConfig(PluginConfig): diff --git a/netbox_config_diff/compliance/base.py b/netbox_config_diff/compliance/base.py index 2e2c3f8..fb9702a 100644 --- a/netbox_config_diff/compliance/base.py +++ b/netbox_config_diff/compliance/base.py @@ -9,10 +9,11 @@ from dcim.models import Device, DeviceRole, Site from django.conf import settings from django.db.models import Q -from extras.scripts import MultiObjectVar, ObjectVar +from extras.scripts import MultiObjectVar, ObjectVar, TextVar from jinja2.exceptions import TemplateError from netutils.config.compliance import diff_network_config from utilities.exceptions import AbortScript +from utilities.utils import render_jinja2 from netbox_config_diff.models import ConplianceDeviceDataClass @@ -52,6 +53,11 @@ class ConfigDiffBase(SecretsMixin): }, description="Define synced DataSource, if you want compare configs stored in it wihout connecting to devices", ) + name_template = TextVar( + required=False, + description="Jinja2 template code for the device name in Data source. " + "Reference the object as {{ object }}.", + ) def run_script(self, data: dict) -> None: devices = self.validate_data(data) @@ -155,17 +161,26 @@ def get_devices_with_rendered_configs(self, devices: Iterable[Device]) -> Iterat auth_secondary=auth_secondary, rendered_config=rendered_config, error=error, + device=device, ) def get_config_from_datasource(self, devices: list[ConplianceDeviceDataClass]) -> None: for device in devices: - if df := DataFile.objects.filter(source=self.data["data_source"], path__icontains=device.name).first(): + if self.data["name_template"]: + try: + device_name = render_jinja2(self.data["name_template"], {"object": device.device}).strip() + except Exception as e: + self.log_failure(f"Error in rendering data source name for {device.name}: {e}, using device name.") + device_name = device.name + else: + device_name = device.name + if df := DataFile.objects.filter(source=self.data["data_source"], path__icontains=device_name).first(): if config := df.data_as_string: device.actual_config = config else: device.error = f"Data in file {df} is broken, skiping device {device.name}" else: - device.error = f"Not found file in DataSource for device {device.name}" + device.error = f"Not found file in DataSource for name {device_name}" def get_actual_configs(self, devices: list[ConplianceDeviceDataClass]) -> None: if self.data["data_source"]: diff --git a/netbox_config_diff/migrations/0008_alter_configcompliance_device.py b/netbox_config_diff/migrations/0008_alter_configcompliance_device.py new file mode 100644 index 0000000..1972edc --- /dev/null +++ b/netbox_config_diff/migrations/0008_alter_configcompliance_device.py @@ -0,0 +1,21 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("dcim", "0181_rename_device_role_device_role"), + ("netbox_config_diff", "0007_configurationrequest"), + ] + + operations = [ + migrations.AlterField( + model_name="configcompliance", + name="device", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="config_compliance", + to="dcim.device", + ), + ), + ] diff --git a/netbox_config_diff/models/data_models.py b/netbox_config_diff/models/data_models.py index 43162ce..1567bbc 100644 --- a/netbox_config_diff/models/data_models.py +++ b/netbox_config_diff/models/data_models.py @@ -1,6 +1,7 @@ import traceback from dataclasses import dataclass +from dcim.models import Device from scrapli import AsyncScrapli from netbox_config_diff.choices import ConfigComplianceStatusChoices @@ -113,10 +114,12 @@ def send_to_db(self) -> None: class ConplianceDeviceDataClass(BaseDeviceDataClass): command: str + device: Device | None = None - def __init__(self, command: str, **kwargs) -> None: + def __init__(self, command: str, device: Device, **kwargs) -> None: super().__init__(**kwargs) self.command = command + self.device = device async def get_actual_config(self) -> None: if self.error is not None: diff --git a/netbox_config_diff/models/models.py b/netbox_config_diff/models/models.py index 4bb0dfd..10b0f73 100644 --- a/netbox_config_diff/models/models.py +++ b/netbox_config_diff/models/models.py @@ -24,7 +24,7 @@ class ConfigCompliance(AbsoluteURLMixin, ChangeLoggingMixin, models.Model): device = models.OneToOneField( to="dcim.Device", on_delete=models.CASCADE, - related_name="config_compliamce", + related_name="config_compliance", ) status = models.CharField( max_length=50, diff --git a/netbox_config_diff/navigation.py b/netbox_config_diff/navigation.py index ddada5d..c52f406 100644 --- a/netbox_config_diff/navigation.py +++ b/netbox_config_diff/navigation.py @@ -1,4 +1,4 @@ -from extras.plugins import PluginMenuButton, PluginMenuItem +from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem from utilities.choices import ButtonColorChoices @@ -12,35 +12,36 @@ def get_add_button(model: str) -> PluginMenuButton: ) -menu_items = ( - PluginMenuItem( - link="plugins:netbox_config_diff:platformsetting_list", - link_text="Platform Settings", - buttons=[get_add_button("platformsetting")], - permissions=["netbox_config_diff.view_platformsetting"], - ), - PluginMenuItem( - link="plugins:netbox_config_diff:configcompliance_list", - link_text="Config Compliances", - buttons=[], - permissions=["netbox_config_diff.view_configcompliance"], - ), - PluginMenuItem( - link="plugins:netbox_config_diff:configurationrequest_list", - link_text="Configuration Requests", - buttons=[get_add_button("configurationrequest")], - permissions=["netbox_config_diff.view_configurationrequest"], - ), +def get_menu_item(model: str, verbose_name: str, add_button: bool = True) -> PluginMenuItem: + return PluginMenuItem( + link=f"plugins:netbox_config_diff:{model}_list", + link_text=verbose_name, + buttons=[get_add_button(model)] if add_button else [], + permissions=[f"netbox_config_diff.view_{model}"], + ) + + +compliance_items = ( + get_menu_item("platformsetting", "Platform Settings"), + get_menu_item("configcompliance", "Config Compliances", add_button=False), +) + +config_items = ( + get_menu_item("configurationrequest", "Configuration Requests"), + get_menu_item("substitute", "Substitutes"), PluginMenuItem( link="plugins:netbox_config_diff:configurationrequest_job_list", link_text="Jobs", buttons=[], permissions=["core.view_job"], ), - PluginMenuItem( - link="plugins:netbox_config_diff:substitute_list", - link_text="Substitutes", - buttons=[get_add_button("substitute")], - permissions=["netbox_config_diff.view_substitute"], +) + +menu = PluginMenu( + label="Config Diff Plugin", + groups=( + ("Compliance", compliance_items), + ("Config Management", config_items), ), + icon_class="mdi mdi-vector-difference", ) diff --git a/netbox_config_diff/templates/netbox_config_diff/configcompliance.html b/netbox_config_diff/templates/netbox_config_diff/configcompliance.html index 50bc5d6..2505fc7 100644 --- a/netbox_config_diff/templates/netbox_config_diff/configcompliance.html +++ b/netbox_config_diff/templates/netbox_config_diff/configcompliance.html @@ -1,81 +1,13 @@ -{% extends "netbox_config_diff/configcompliance/base.html" %} -{% load static %} +{% extends "generic/object.html" %} +{% load buttons %} +{% load perms %} -{% block content %} -
-
-
-
{{ object|meta:"verbose_name"|bettertitle }}
-
- - - - - - - - - -
Device{{ object.device|linkify }}
Status{% badge object.get_status_display bg_color=object.get_status_color %}
-
-
+{% block controls %} +
+
+ {% if request.user|can_delete:object %} + {% delete_button object %} + {% endif %}
- {% if object.error %} -
-
-
Error
-
-
{{ object.error }}
-
-
-
- {% endif %}
- {% if object.diff %} -
-
-
-
Diff
-
-
-
-
- {% endif %} -{% endblock content %} - -{% block javascript %} - - -{% endblock javascript %} +{% endblock controls %} diff --git a/netbox_config_diff/templates/netbox_config_diff/configcompliance/base.html b/netbox_config_diff/templates/netbox_config_diff/configcompliance/base.html deleted file mode 100644 index 8fdcc6f..0000000 --- a/netbox_config_diff/templates/netbox_config_diff/configcompliance/base.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "generic/object.html" %} -{% load buttons %} -{% load perms %} - -{% block controls %} -
-
- {% if request.user|can_delete:object %} - {% delete_button object %} - {% endif %} -
-
-{% endblock controls %} \ No newline at end of file diff --git a/netbox_config_diff/templates/netbox_config_diff/configcompliance/config.html b/netbox_config_diff/templates/netbox_config_diff/configcompliance/config.html index 8bc2722..ae2c864 100644 --- a/netbox_config_diff/templates/netbox_config_diff/configcompliance/config.html +++ b/netbox_config_diff/templates/netbox_config_diff/configcompliance/config.html @@ -1,4 +1,4 @@ -{% extends "netbox_config_diff/configcompliance/base.html" %} +{% extends "netbox_config_diff/configcompliance.html" %} {% block title %}{{ object }} - {{ header }}{% endblock %} diff --git a/netbox_config_diff/templates/netbox_config_diff/configcompliance/data.html b/netbox_config_diff/templates/netbox_config_diff/configcompliance/data.html new file mode 100644 index 0000000..5c20e42 --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/configcompliance/data.html @@ -0,0 +1,81 @@ +{% extends base_template %} +{% load static %} + +{% block content %} +
+
+
+
{{ instance|meta:"verbose_name"|bettertitle }}
+
+ + + + + + + + + +
Device{{ instance.device|linkify }}
Status{% badge instance.get_status_display bg_color=instance.get_status_color %}
+
+
+
+ {% if instance.error %} +
+
+
Error
+
+
{{ instance.error }}
+
+
+
+ {% endif %} +
+ {% if instance.diff %} +
+
+
+
Diff
+
+
+
+
+ {% endif %} +{% endblock content %} + +{% block javascript %} + + +{% endblock javascript %} diff --git a/netbox_config_diff/templates/netbox_config_diff/configcompliance/missing_extra.html b/netbox_config_diff/templates/netbox_config_diff/configcompliance/missing_extra.html index c7692fd..de94dd9 100644 --- a/netbox_config_diff/templates/netbox_config_diff/configcompliance/missing_extra.html +++ b/netbox_config_diff/templates/netbox_config_diff/configcompliance/missing_extra.html @@ -1,4 +1,4 @@ -{% extends "netbox_config_diff/configcompliance/base.html" %} +{% extends "netbox_config_diff/configcompliance.html" %} {% block title %}{{ object }} - Missing/Extra{% endblock %} diff --git a/netbox_config_diff/views/compliance.py b/netbox_config_diff/views/compliance.py index fbe5c95..2a4d49c 100644 --- a/netbox_config_diff/views/compliance.py +++ b/netbox_config_diff/views/compliance.py @@ -1,5 +1,6 @@ +from dcim.models import Device from django.http import HttpResponse -from django.shortcuts import render +from django.shortcuts import redirect, render from django.utils.translation import gettext as _ from netbox.views import generic from utilities.views import ViewTab, register_model_view @@ -51,6 +52,14 @@ def get_extra_context(self, request, instance): @register_model_view(ConfigCompliance) class ConfigComplianceView(generic.ObjectView): queryset = ConfigCompliance.objects.all() + base_template = "netbox_config_diff/configcompliance.html" + template_name = "netbox_config_diff/configcompliance/data.html" + + def get_extra_context(self, request, instance): + return { + "instance": instance, + "base_template": self.base_template, + } @register_model_view(ConfigCompliance, "rendered-config") @@ -113,6 +122,37 @@ def get(self, request, **kwargs): ) +@register_model_view(Device, "config_compliance", "config-compliance") +class ConfigComplianceDeviceView(generic.ObjectView): + queryset = Device.objects.all() + base_template = "dcim/device/base.html" + template_name = "netbox_config_diff/configcompliance/data.html" + tab = ViewTab( + label=_("Config Compliance"), + weight=2110, + badge=lambda obj: 1 if hasattr(obj, "config_compliance") else 0, + hide_if_empty=True, + ) + + def get(self, request, **kwargs): + instance = self.get_object(**kwargs) + + if not hasattr(instance, "config_compliance"): + return redirect("dcim:device", pk=instance.pk) + + return render( + request, + self.get_template_name(), + { + "object": instance, + "instance": instance.config_compliance, + "tab": self.tab, + "base_template": self.base_template, + **self.get_extra_context(request, instance), + }, + ) + + class ConfigComplianceListView(generic.ObjectListView): queryset = ConfigCompliance.objects.prefetch_related("device") filterset = ConfigComplianceFilterSet diff --git a/pyproject.toml b/pyproject.toml index 0ae9cf3..42a1404 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires = [ [project] name = "netbox-config-diff" -description = "Find diff between the intended device configuration and actual." +description = "Push rendered device configurations from NetBox to devices and apply them." readme = "README.md" keywords = [ "netbox", diff --git a/tests/conftest.py b/tests/conftest.py index 692d0a9..da9e32f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -90,6 +90,7 @@ def factory(**fields: Unpack["DeviceDataClassData"]) -> "DeviceDataClassData": "password": faker.password(), "auth_strict_key": False, "transport": "asyncssh", + "device": None, } return data | fields