diff --git a/docs/admin/release_notes/version_2.2.md b/docs/admin/release_notes/version_2.2.md index dae668d8..b4ccece9 100644 --- a/docs/admin/release_notes/version_2.2.md +++ b/docs/admin/release_notes/version_2.2.md @@ -8,6 +8,18 @@ This document describes all new features and changes in the release. The format - Added Python 3.12 support. - Added REST API endpoint for Jinja as first part of journey towards a jinja live editor. +## [v2.2.2 (2024-12-17)](https://github.com/nautobot/nautobot-app-golden-config/releases/tag/v2.2.2) + +### Added + +- [#840](https://github.com/nautobot/nautobot-app-golden-config/issues/840) - Added GraphQL output to the "Generate Intended Config" view. +- [#841](https://github.com/nautobot/nautobot-app-golden-config/issues/841) - Added GraphQL query form field to the "Generate Intended Config" view. +- [#844](https://github.com/nautobot/nautobot-app-golden-config/issues/844) - Added a diff output to the "Generate Intended Config" view and associated REST API. + +### Fixed + +- [#849](https://github.com/nautobot/nautobot-app-golden-config/issues/849) - Fixed failing tests in Nautobot v2.3.13. + ## [v2.2.1 (2024-11-27)](https://github.com/nautobot/nautobot-app-golden-config/releases/tag/v2.2.1) ### Added diff --git a/docs/images/generate-intended-config-ui-dark.png b/docs/images/generate-intended-config-ui-dark.png index 7980b92c..a8c218ee 100644 Binary files a/docs/images/generate-intended-config-ui-dark.png and b/docs/images/generate-intended-config-ui-dark.png differ diff --git a/docs/images/generate-intended-config-ui.png b/docs/images/generate-intended-config-ui.png index 604a6ab3..782c5023 100644 Binary files a/docs/images/generate-intended-config-ui.png and b/docs/images/generate-intended-config-ui.png differ diff --git a/docs/user/app_feature_intended.md b/docs/user/app_feature_intended.md index a27170a5..b2319ef6 100644 --- a/docs/user/app_feature_intended.md +++ b/docs/user/app_feature_intended.md @@ -45,7 +45,9 @@ curl -s -X GET \ http://nautobot/api/plugins/golden-config/generate-intended-config/?device_id=231b8765-054d-4abe-bdbf-cd60e049cd8d ``` -The returned response will contain the rendered configuration for the specified device. The web UI provides a simple form to input the device and displays the rendered configuration when submitted. +The returned response will contain the rendered configuration for the specified device, the GraphQL data that was used, and if applicable, a diff of the most recent intended config that was generated by the **Intended Configuration** job. The web UI provides a simple form to interact with this REST API. You can access the web UI by clicking on "Generate Intended Config" in the "Tools" section of the Golden Config navigation menu. + +For more advanced use cases, the REST API and web UI also accept a `graphql_query_id` parameter to specify a custom GraphQL query to use when rendering the configuration. If a `graphql_query_id` is not provided, the default query configured in the Device's Golden Config settings will be used. ![Intended Configuration Web UI](../images/generate-intended-config-ui.png#only-light) ![Intended Configuration Web UI](../images/generate-intended-config-ui-dark.png#only-dark) diff --git a/nautobot_golden_config/api/serializers.py b/nautobot_golden_config/api/serializers.py index d67f57d4..73c30a54 100644 --- a/nautobot_golden_config/api/serializers.py +++ b/nautobot_golden_config/api/serializers.py @@ -131,3 +131,6 @@ class GenerateIntendedConfigSerializer(serializers.Serializer): # pylint: disab intended_config = serializers.CharField(read_only=True) intended_config_lines = serializers.ListField(read_only=True, child=serializers.CharField()) + graphql_data = serializers.JSONField(read_only=True) + diff = serializers.CharField(read_only=True) + diff_lines = serializers.ListField(read_only=True, child=serializers.CharField()) diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py index acba6b2c..11c23951 100644 --- a/nautobot_golden_config/api/views.py +++ b/nautobot_golden_config/api/views.py @@ -1,6 +1,7 @@ """View for Golden Config APIs.""" import datetime +import difflib import json import logging from pathlib import Path @@ -20,6 +21,7 @@ from nautobot.dcim.models import Device from nautobot.extras.api.views import NautobotModelViewSet, NotesViewSetMixin from nautobot.extras.datasources.git import ensure_git_repository +from nautobot.extras.models import GraphQLQuery from nautobot_plugin_nornir.constants import NORNIR_SETTINGS from nornir import InitNornir from nornir_nautobot.plugins.tasks.dispatcher import dispatcher @@ -202,6 +204,26 @@ class GenerateIntendedConfigView(NautobotAPIVersionMixin, GenericAPIView): permission_classes = [IsAuthenticated] serializer_class = serializers.GenerateIntendedConfigSerializer + def _get_diff(self, device, intended_config): + """Generate a unified diff between the provided config and the intended config stored on the Device's GoldenConfig.intended_config.""" + diff = None + try: + golden_config = device.goldenconfig + if golden_config.intended_last_success_date is not None: + prior_intended_config = golden_config.intended_config + diff = "".join( + difflib.unified_diff( + prior_intended_config.splitlines(keepends=True), + intended_config.splitlines(keepends=True), + fromfile="prior intended config", + tofile="rendered config", + ) + ) + except models.GoldenConfig.DoesNotExist: + pass + + return diff + def _get_object(self, request, model, query_param): """Get the requested model instance, restricted to requesting user.""" pk = request.query_params.get(query_param) @@ -232,19 +254,41 @@ def _get_jinja_template_path(self, settings, device, git_repository): type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, ), + OpenApiParameter( + name="graphql_query_id", + required=False, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + ), ] ) def get(self, request, *args, **kwargs): """Generate intended configuration for a Device.""" device = self._get_object(request, Device, "device_id") + graphql_query = None + graphql_query_id_param = request.query_params.get("graphql_query_id") + if graphql_query_id_param: + try: + graphql_query = GraphQLQuery.objects.get(pk=request.query_params.get("graphql_query_id")) + except GraphQLQuery.DoesNotExist as exc: + raise GenerateIntendedConfigException( + f"GraphQLQuery with id '{graphql_query_id_param}' not found" + ) from exc settings = models.GoldenConfigSetting.objects.get_for_device(device) if not settings: raise GenerateIntendedConfigException("No Golden Config settings found for this device") - if not settings.sot_agg_query: - raise GenerateIntendedConfigException("Golden Config settings sot_agg_query not set") if not settings.jinja_repository: raise GenerateIntendedConfigException("Golden Config settings jinja_repository not set") + if graphql_query is None: + if settings.sot_agg_query is not None: + graphql_query = settings.sot_agg_query + else: + raise GenerateIntendedConfigException("Golden Config settings sot_agg_query not set") + + if "device_id" not in graphql_query.variables: + raise GenerateIntendedConfigException("The selected GraphQL query is missing a 'device_id' variable") + try: git_repository = settings.jinja_repository ensure_git_repository(git_repository) @@ -253,21 +297,27 @@ def get(self, request, *args, **kwargs): filesystem_path = self._get_jinja_template_path(settings, device, git_repository) - status_code, context = graph_ql_query(request, device, settings.sot_agg_query.query) + status_code, graphql_data = graph_ql_query(request, device, graphql_query.query) if status_code == status.HTTP_200_OK: try: intended_config = self._render_config_nornir_serial( device=device, jinja_template=filesystem_path.name, jinja_root_path=filesystem_path.parent, - graphql_data=context, + graphql_data=graphql_data, ) except Exception as exc: raise GenerateIntendedConfigException(f"Error rendering Jinja template: {exc}") from exc + + diff = self._get_diff(device, intended_config) + return Response( data={ "intended_config": intended_config, "intended_config_lines": intended_config.split("\n"), + "graphql_data": graphql_data, + "diff": diff, + "diff_lines": diff.split("\n") if diff else [], }, status=status.HTTP_200_OK, ) diff --git a/nautobot_golden_config/filter_extensions.py b/nautobot_golden_config/filter_extensions.py new file mode 100644 index 00000000..276b62a0 --- /dev/null +++ b/nautobot_golden_config/filter_extensions.py @@ -0,0 +1,30 @@ +"""FilterSet and FilterForm extensions for the Golden Config app.""" + +from django.db.models import Q +from nautobot.apps.filters import FilterExtension, MultiValueCharFilter + + +def filter_graphql_query_variables(queryset, name, value): # pylint: disable=unused-argument + """Filter the queryset based on the presence of variables.""" + query = Q() + for variable_name in value: + query |= Q(**{f"variables__{variable_name}__isnull": True}) + return queryset.exclude(query) + + +class GraphQLQueryFilterExtension(FilterExtension): + """Filter extensions for the extras.GraphQLQuery model.""" + + model = "extras.graphqlquery" + + filterset_fields = { + "nautobot_golden_config_graphql_query_variables": MultiValueCharFilter( + field_name="variables", + lookup_expr="exact", + method=filter_graphql_query_variables, + label="Variable key(s) exist", + ), + } + + +filter_extensions = [GraphQLQueryFilterExtension] diff --git a/nautobot_golden_config/filters.py b/nautobot_golden_config/filters.py index c3193289..2223200a 100644 --- a/nautobot_golden_config/filters.py +++ b/nautobot_golden_config/filters.py @@ -245,6 +245,27 @@ class Meta: class GoldenConfigSettingFilterSet(NautobotFilterSet): """Inherits Base Class NautobotFilterSet.""" + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label="Device (ID)", + method="filter_device_id", + ) + + def filter_device_id(self, queryset, name, value): # pylint: disable=unused-argument + """Filter by Device ID.""" + if not value: + return queryset + golden_config_setting_ids = [] + for instance in value: + if isinstance(instance, Device): + device = instance + else: + device = Device.objects.get(id=instance) + golden_config_setting = models.GoldenConfigSetting.objects.get_for_device(device) + if golden_config_setting is not None: + golden_config_setting_ids.append(golden_config_setting.id) + return queryset.filter(id__in=golden_config_setting_ids) + class Meta: """Boilerplate filter Meta data for Config Remove.""" diff --git a/nautobot_golden_config/forms.py b/nautobot_golden_config/forms.py index 1c7a5740..2bd45559 100644 --- a/nautobot_golden_config/forms.py +++ b/nautobot_golden_config/forms.py @@ -7,7 +7,7 @@ from nautobot.apps import forms from nautobot.dcim.models import Device, DeviceType, Location, Manufacturer, Platform, Rack, RackGroup from nautobot.extras.forms import NautobotBulkEditForm, NautobotFilterForm, NautobotModelForm -from nautobot.extras.models import DynamicGroup, GitRepository, JobResult, Role, Status, Tag +from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, JobResult, Role, Status, Tag from nautobot.tenancy.models import Tenant, TenantGroup from nautobot_golden_config import models @@ -608,3 +608,9 @@ class GenerateIntendedConfigForm(django_forms.Form): required=True, label="Device", ) + graphql_query = forms.DynamicModelChoiceField( + queryset=GraphQLQuery.objects.all(), + required=True, + label="GraphQL Query", + query_params={"nautobot_golden_config_graphql_query_variables": "device_id"}, + ) diff --git a/nautobot_golden_config/tables.py b/nautobot_golden_config/tables.py index 9c1386ef..57adbaaf 100644 --- a/nautobot_golden_config/tables.py +++ b/nautobot_golden_config/tables.py @@ -63,7 +63,7 @@ {% if record.config_type == 'json' %} {% else %} - diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_devicetab.html b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_devicetab.html index e42c4a69..c44e0087 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_devicetab.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_devicetab.html @@ -5,7 +5,8 @@ {% block title %} {{ object }} - Config Compliance {% endblock %} -{% block content %} +{% block extra_styles %} +{{ block.super }} +{% endblock extra_styles %} + +{% block content %} {% block navigation %}
-
Feature Navigation - Compliant - Non-Compliant - Clear +
Feature Navigation + Compliant + Non-Compliant + Clear
{% endfor %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_overview.html b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_overview.html index e6e741e6..8d6a1325 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_overview.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_overview.html @@ -6,7 +6,7 @@ {% block title %}Overview Reports{% endblock %} {% block breadcrumbs %} -
  • Overview Reports
  • +
  • Overview Reports
  • {% block extra_breadcrumbs %}{% endblock extra_breadcrumbs %} {% endblock breadcrumbs %} @@ -74,5 +74,6 @@

    Feature Summary

    {% table_config_form table table_name="ObjectTable" %} {% endblock %} {% block javascript %} +{{ block.super }} {% endblock %} diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_retrieve.html b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_retrieve.html index d3fb5921..e544d072 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_retrieve.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_retrieve.html @@ -2,13 +2,7 @@ {% load helpers %} {% load static %} -{% block content_left_page %} - - +{% block extra_styles %} +{% endblock extra_styles %} + +{% block content_left_page %}
    Details @@ -90,4 +87,13 @@
    {% include 'inc/custom_fields_panel.html' %} {% include 'inc/relationships_panel.html' %} -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block javascript %} +{{ block.super }} + +{% endblock javascript %} diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configplan_create.html b/nautobot_golden_config/templates/nautobot_golden_config/configplan_create.html index e7aecf8c..55dca93c 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/configplan_create.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/configplan_create.html @@ -47,6 +47,7 @@ {% endblock %} {% block javascript %} +{{ block.super }} diff --git a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_detailsmodal.html b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_detailsmodal.html index e895377e..ebbb17d2 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_detailsmodal.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_detailsmodal.html @@ -2,8 +2,7 @@ - - \ No newline at end of file + diff --git a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_list.html b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_list.html index 0c662ec3..f8a38c5c 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_list.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_list.html @@ -5,8 +5,33 @@

    {% block title %}Configuration Overview{% endblock %}

    +{% block extra_styles %} + +{% endblock extra_styles %} + {% block buttons %} -