From 9a304e3561ce8ec85d9fb535a775bb06b9bc331f Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Sun, 12 May 2024 15:31:19 +0200 Subject: [PATCH] Add patch commands to compliance --- .github/workflows/commit.yaml | 2 +- netbox_config_diff/api/serializers.py | 1 + netbox_config_diff/compliance/base.py | 13 +++- netbox_config_diff/compliance/utils.py | 17 +++++ netbox_config_diff/configurator/base.py | 5 +- .../migrations/0009_configcompliance_patch.py | 16 +++++ netbox_config_diff/models/data_models.py | 2 + netbox_config_diff/models/models.py | 3 + .../configcompliance/config.html | 3 +- .../configcompliance/missing_extra.html | 32 +-------- .../configcompliance/patch.html | 11 +++ .../netbox_config_diff/inc/commands_card.html | 16 +++++ netbox_config_diff/views/base.py | 41 ++++++++++- netbox_config_diff/views/compliance.py | 69 ++++++++----------- requirements/base.txt | 1 + 15 files changed, 157 insertions(+), 75 deletions(-) create mode 100644 netbox_config_diff/migrations/0009_configcompliance_patch.py create mode 100644 netbox_config_diff/templates/netbox_config_diff/configcompliance/patch.html create mode 100644 netbox_config_diff/templates/netbox_config_diff/inc/commands_card.html diff --git a/.github/workflows/commit.yaml b/.github/workflows/commit.yaml index 23b3ca3..6a12548 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.9", "v3.7.5"] + netbox_version: ["v3.5.9", "v3.6.9", "v3.7.8"] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/netbox_config_diff/api/serializers.py b/netbox_config_diff/api/serializers.py index c636a28..9b3a12d 100644 --- a/netbox_config_diff/api/serializers.py +++ b/netbox_config_diff/api/serializers.py @@ -29,6 +29,7 @@ class Meta: "diff", "rendered_config", "actual_config", + "patch", "missing", "extra", "created", diff --git a/netbox_config_diff/compliance/base.py b/netbox_config_diff/compliance/base.py index fb9702a..187f05d 100644 --- a/netbox_config_diff/compliance/base.py +++ b/netbox_config_diff/compliance/base.py @@ -18,7 +18,14 @@ from netbox_config_diff.models import ConplianceDeviceDataClass from .secrets import SecretsMixin -from .utils import PLATFORM_MAPPING, CustomChoiceVar, exclude_lines, get_unified_diff +from .utils import ( + PLATFORM_MAPPING, + REMEDIATION_MAPPING, + CustomChoiceVar, + exclude_lines, + get_remediation_commands, + get_unified_diff, +) class ConfigDiffBase(SecretsMixin): @@ -204,3 +211,7 @@ def get_diff(self, devices: list[ConplianceDeviceDataClass]) -> None: device.extra = diff_network_config( cleaned_config, device.rendered_config, PLATFORM_MAPPING[device.platform] ) + if device.platform in REMEDIATION_MAPPING: + device.patch = get_remediation_commands( + device.name, device.platform, cleaned_config, device.rendered_config + ) diff --git a/netbox_config_diff/compliance/utils.py b/netbox_config_diff/compliance/utils.py index 1da8fc7..6802258 100644 --- a/netbox_config_diff/compliance/utils.py +++ b/netbox_config_diff/compliance/utils.py @@ -3,6 +3,7 @@ from django.forms import ChoiceField from extras.scripts import ScriptVariable +from hier_config import Host PLATFORM_MAPPING = { "arista_eos": "arista_eos", @@ -19,6 +20,15 @@ "ruckus_fastiron": "ruckus_fastiron", } +REMEDIATION_MAPPING = { + "arista_eos": "eos", + "cisco_iosxe": "ios", + "cisco_iosxr": "iosxr", + "cisco_nxos": "nxos", + "juniper_junos": "junos", + "vyos_vyos": "vyos", +} + class CustomChoiceVar(ScriptVariable): form_field = ChoiceField @@ -43,3 +53,10 @@ def exclude_lines(text: str, regexs: list) -> str: for item in regexs: text = re.sub(item, "", text, flags=re.I | re.M) return text.strip() + + +def get_remediation_commands(name: str, platform: str, actual_config: str, rendered_config: str) -> str: + host = Host(hostname=name, os=REMEDIATION_MAPPING.get(platform)) + host.load_running_config(config_text=actual_config) + host.load_generated_config(config_text=rendered_config) + return host.remediation_config_filtered_text(include_tags={}, exclude_tags={}) diff --git a/netbox_config_diff/configurator/base.py b/netbox_config_diff/configurator/base.py index 38bc94b..21c81df 100644 --- a/netbox_config_diff/configurator/base.py +++ b/netbox_config_diff/configurator/base.py @@ -14,7 +14,7 @@ from utilities.utils import NetBoxFakeRequest from netbox_config_diff.compliance.secrets import SecretsMixin -from netbox_config_diff.compliance.utils import PLATFORM_MAPPING, get_unified_diff +from netbox_config_diff.compliance.utils import PLATFORM_MAPPING, get_remediation_commands, get_unified_diff from netbox_config_diff.configurator.exceptions import DeviceConfigurationError, DeviceValidationError from netbox_config_diff.configurator.utils import CustomLogger from netbox_config_diff.constants import ACCEPTABLE_DRIVERS @@ -137,6 +137,9 @@ async def _collect_one_diff(self, device: ConfiguratorDeviceDataClass) -> None: device.extra = diff_network_config( device.actual_config, device.rendered_config, PLATFORM_MAPPING[device.platform] ) + device.patch = get_remediation_commands( + device.name, device.platform, device.actual_config, device.rendered_config + ) self.logger.log_info(f"Got diff from {device.name}") except Exception: error = traceback.format_exc() diff --git a/netbox_config_diff/migrations/0009_configcompliance_patch.py b/netbox_config_diff/migrations/0009_configcompliance_patch.py new file mode 100644 index 0000000..5ad5620 --- /dev/null +++ b/netbox_config_diff/migrations/0009_configcompliance_patch.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_config_diff", "0008_alter_configcompliance_device"), + ] + + operations = [ + migrations.AddField( + model_name="configcompliance", + name="patch", + field=models.TextField(blank=True), + ), + ] diff --git a/netbox_config_diff/models/data_models.py b/netbox_config_diff/models/data_models.py index 1567bbc..99539a4 100644 --- a/netbox_config_diff/models/data_models.py +++ b/netbox_config_diff/models/data_models.py @@ -23,6 +23,7 @@ class BaseDeviceDataClass: diff: str = "" missing: str | None = None extra: str | None = None + patch: str | None = None error: str = "" config_error: str | None = None auth_strict_key: bool = False @@ -99,6 +100,7 @@ def to_db(self) -> dict: "actual_config": self.actual_config or "", "missing": self.missing or "", "extra": self.extra or "", + "patch": self.patch or "", } def send_to_db(self) -> None: diff --git a/netbox_config_diff/models/models.py b/netbox_config_diff/models/models.py index 10b0f73..8fd7493 100644 --- a/netbox_config_diff/models/models.py +++ b/netbox_config_diff/models/models.py @@ -49,6 +49,9 @@ class ConfigCompliance(AbsoluteURLMixin, ChangeLoggingMixin, models.Model): extra = models.TextField( blank=True, ) + patch = models.TextField( + blank=True, + ) objects = RestrictedQuerySet.as_manager() 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 ae2c864..874799a 100644 --- a/netbox_config_diff/templates/netbox_config_diff/configcompliance/config.html +++ b/netbox_config_diff/templates/netbox_config_diff/configcompliance/config.html @@ -8,6 +8,7 @@
+ {% copy_content config_field %} Download @@ -15,7 +16,7 @@
{{ header }}
{% if config %} -
{{ config }}
+
{{ config }}
{% else %}
No configuration
{% endif %} 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 de94dd9..5de7f9e 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 @@ -5,38 +5,10 @@ {% block content %}
-
-
- -
Missing
-
- {% if object.missing %} -
{{ object.missing }}
- {% else %} -
No lines
- {% endif %} -
+ {% include 'netbox_config_diff/inc/commands_card.html' with data=object.missing header='Missing' pre_id='missing' %}
-
-
- -
Extra
-
- {% if object.extra %} -
{{ object.extra }}
- {% else %} -
No lines
- {% endif %} -
+ {% include 'netbox_config_diff/inc/commands_card.html' with data=object.extra header='Extra' pre_id='extra' %}
{% endblock %} diff --git a/netbox_config_diff/templates/netbox_config_diff/configcompliance/patch.html b/netbox_config_diff/templates/netbox_config_diff/configcompliance/patch.html new file mode 100644 index 0000000..ac77fe7 --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/configcompliance/patch.html @@ -0,0 +1,11 @@ +{% extends "netbox_config_diff/configcompliance.html" %} + +{% block title %}{{ object }} - Patch commands{% endblock %} + +{% block content %} +
+
+ {% include 'netbox_config_diff/inc/commands_card.html' with data=object.patch header='Patch commands' pre_id='patch' %} +
+
+{% endblock %} diff --git a/netbox_config_diff/templates/netbox_config_diff/inc/commands_card.html b/netbox_config_diff/templates/netbox_config_diff/inc/commands_card.html new file mode 100644 index 0000000..dae39c8 --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/inc/commands_card.html @@ -0,0 +1,16 @@ +
+
+
+ {% copy_content pre_id %} + + Download + +
+
{{ header }}
+
+ {% if data %} +
{{ data }}
+ {% else %} +
No commands
+ {% endif %} +
diff --git a/netbox_config_diff/views/base.py b/netbox_config_diff/views/base.py index 43cc8de..b01a7b8 100644 --- a/netbox_config_diff/views/base.py +++ b/netbox_config_diff/views/base.py @@ -1,5 +1,7 @@ +from django.http import HttpResponse +from django.shortcuts import render from django.urls import reverse -from netbox.views.generic import ObjectDeleteView, ObjectEditView +from netbox.views.generic import ObjectDeleteView, ObjectEditView, ObjectView class BaseObjectDeleteView(ObjectDeleteView): @@ -11,3 +13,40 @@ class BaseObjectEditView(ObjectEditView): @property def default_return_url(self) -> str: return f"plugins:netbox_config_diff:{self.queryset.model._meta.model_name}_list" + + +class BaseExportView(ObjectView): + def export_parts(self, name, lines, suffix): + response = HttpResponse(lines, content_type="text") + filename = f"{name}_{suffix}.txt" + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + + +class BaseConfigComplianceConfigView(BaseExportView): + config_field = None + template_header = None + + def get(self, request, **kwargs): + instance = self.get_object(**kwargs) + context = self.get_extra_context(request, instance) + + if request.GET.get("export"): + return self.export_parts(instance.device.name, context["config"], self.config_field) + + return render( + request, + self.get_template_name(), + { + "object": instance, + "tab": self.tab, + **context, + }, + ) + + def get_extra_context(self, request, instance): + return { + "header": self.template_header, + "config": getattr(instance, self.config_field), + "config_field": self.config_field, + } diff --git a/netbox_config_diff/views/compliance.py b/netbox_config_diff/views/compliance.py index 2a4d49c..a5b0d5b 100644 --- a/netbox_config_diff/views/compliance.py +++ b/netbox_config_diff/views/compliance.py @@ -1,5 +1,4 @@ from dcim.models import Device -from django.http import HttpResponse from django.shortcuts import redirect, render from django.utils.translation import gettext as _ from netbox.views import generic @@ -15,38 +14,7 @@ from netbox_config_diff.models import ConfigCompliance, PlatformSetting from netbox_config_diff.tables import ConfigComplianceTable, PlatformSettingTable -from .base import BaseObjectDeleteView, BaseObjectEditView - - -class BaseConfigComplianceConfigView(generic.ObjectView): - config_field = None - template_header = None - - def get(self, request, **kwargs): - instance = self.get_object(**kwargs) - context = self.get_extra_context(request, instance) - - if request.GET.get("export"): - response = HttpResponse(context["config"], content_type="text") - filename = f"{instance.device.name}_{self.config_field}.txt" - response["Content-Disposition"] = f'attachment; filename="{filename}"' - return response - - return render( - request, - self.get_template_name(), - { - "object": instance, - "tab": self.tab, - **context, - }, - ) - - def get_extra_context(self, request, instance): - return { - "header": self.template_header, - "config": getattr(instance, self.config_field), - } +from .base import BaseConfigComplianceConfigView, BaseExportView, BaseObjectDeleteView, BaseObjectEditView @register_model_view(ConfigCompliance) @@ -87,7 +55,7 @@ class ConfigComplianceActualConfigView(BaseConfigComplianceConfigView): @register_model_view(ConfigCompliance, "missing-extra") -class ConfigComplianceMissingExtraConfigView(generic.ObjectView): +class ConfigComplianceMissingExtraConfigView(BaseExportView): queryset = ConfigCompliance.objects.all() template_name = "netbox_config_diff/configcompliance/missing_extra.html" tab = ViewTab( @@ -95,12 +63,6 @@ class ConfigComplianceMissingExtraConfigView(generic.ObjectView): weight=520, ) - def export_parts(self, name, lines, suffix): - response = HttpResponse(lines, content_type="text") - filename = f"{name}_{suffix}.txt" - response["Content-Disposition"] = f'attachment; filename="{filename}"' - return response - def get(self, request, **kwargs): instance = self.get_object(**kwargs) context = self.get_extra_context(request, instance) @@ -122,6 +84,33 @@ def get(self, request, **kwargs): ) +@register_model_view(ConfigCompliance, "patch") +class ConfigCompliancePatchView(BaseExportView): + queryset = ConfigCompliance.objects.all() + template_name = "netbox_config_diff/configcompliance/patch.html" + tab = ViewTab( + label=_("Patch"), + weight=515, + ) + + def get(self, request, **kwargs): + instance = self.get_object(**kwargs) + context = self.get_extra_context(request, instance) + + if request.GET.get("export_patch"): + return self.export_parts(instance.device.name, instance.patch, "patch") + + return render( + request, + self.get_template_name(), + { + "object": instance, + "tab": self.tab, + **context, + }, + ) + + @register_model_view(Device, "config_compliance", "config-compliance") class ConfigComplianceDeviceView(generic.ObjectView): queryset = Device.objects.all() diff --git a/requirements/base.txt b/requirements/base.txt index 80304ec..770e18b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,3 +1,4 @@ +hier-config==2.2.3 netutils==1.5.0 scrapli[asyncssh]==2023.07.30 scrapli-cfg==2023.07.30