diff --git a/changes/827.added b/changes/827.added new file mode 100644 index 00000000..d2f8aec3 --- /dev/null +++ b/changes/827.added @@ -0,0 +1 @@ +Added a web ui for Jinja template developers to render intended configurations. diff --git a/docs/images/generate-intended-config-ui-dark.png b/docs/images/generate-intended-config-ui-dark.png new file mode 100644 index 00000000..7980b92c Binary files /dev/null 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 new file mode 100644 index 00000000..604a6ab3 Binary files /dev/null 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 81c78a34..a27170a5 100644 --- a/docs/user/app_feature_intended.md +++ b/docs/user/app_feature_intended.md @@ -35,24 +35,22 @@ In these examples, `/services.j2`, `/ntp.j2`, etc. could contain the actual Jinj ### Developing Intended Configuration Templates -To help developers create the Jinja2 templates for generating the intended configuration, the app provides a REST API at `/api/plugins/golden-config/generate-intended-config/`. This API accepts two query parameters: `device_id` and `git_repository_id`. It returns the rendered configuration for the specified device using the templates from the given Git repository. This feature allows developers to test their configuration templates using a custom `GitRepository` without running a full intended configuration job. +To help developers create the Jinja2 templates for generating a device's intended configuration, the app provides a REST API at `/api/plugins/golden-config/generate-intended-config/` and a simple web UI at `/plugins/golden-config/generate-intended-config/`. The REST API accepts a query parameter for `device_id` and returns the rendered configuration for the specified device using the templates from the device's golden config `jinja_repository` Git repository. This feature allows developers to test their configuration templates without running a full "intended configuration" job. -Here's an example of how to request the rendered configuration for a device: +Here's an example of how to request the rendered configuration for a device using the REST API: ```no-highlight -GET /api/plugins/golden-config/generate-intended-config/?device_id=231b8765-054d-4abe-bdbf-cd60e049cd8d&git_repository_id=82c051e0-d0a9-4008-948a-936a409c654a +curl -s -X GET \ + -H "Accept: application/json" \ + 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. This is the intended workflow for developers: +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. -- Create a new branch in the intended configuration repository. -- Modify the Jinja2 templates in that new branch. -- Add a new `GitRepository` in Nautobot that points to the new branch and sync the repository. - - NOTE: Do not select the "jinja templates" option under the "Provides" field when creating the `GitRepository`. Nautobot does not allow multiple `GitRepository` instances with an identical URL and "Provided Content". This API ignores the "Provided Content" field for this reason. - - Don't forget to associate credentials required to access the repository using the "Secrets Group" field. -- Use the API to render the configuration for a device, using the new `GitRepository`. +![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) -Calling this API endpoint automatically performs a `git pull`, retrieving the latest commit from the branch before rendering the template. +Calling this API endpoint automatically performs a `git pull`, retrieving the latest commit from the Jinja2 templates Git repository before rendering the template. Note that this API is only intended to render Jinja2 templates and does not apply any [configuration post-processing](./app_feature_config_postprocessing.md). diff --git a/nautobot_golden_config/api/serializers.py b/nautobot_golden_config/api/serializers.py index 64ba2120..d67f57d4 100644 --- a/nautobot_golden_config/api/serializers.py +++ b/nautobot_golden_config/api/serializers.py @@ -129,5 +129,5 @@ class Meta: class GenerateIntendedConfigSerializer(serializers.Serializer): # pylint: disable=abstract-method """Serializer for GenerateIntendedConfigView.""" - intended_config = serializers.CharField() - intended_config_lines = serializers.ListField(child=serializers.CharField()) + intended_config = serializers.CharField(read_only=True) + intended_config_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 17358c72..acba6b2c 100644 --- a/nautobot_golden_config/api/views.py +++ b/nautobot_golden_config/api/views.py @@ -20,7 +20,6 @@ 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 GitRepository from nautobot_plugin_nornir.constants import NORNIR_SETTINGS from nornir import InitNornir from nornir_nautobot.plugins.tasks.dispatcher import dispatcher @@ -233,25 +232,21 @@ def _get_jinja_template_path(self, settings, device, git_repository): type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, ), - OpenApiParameter( - name="git_repository_id", - required=True, - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - ), ] ) def get(self, request, *args, **kwargs): - """Generate intended configuration for a Device with an arbitrary GitRepository.""" + """Generate intended configuration for a Device.""" device = self._get_object(request, Device, "device_id") - git_repository = self._get_object(request, GitRepository, "git_repository_id") 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") try: + git_repository = settings.jinja_repository ensure_git_repository(git_repository) except Exception as exc: raise GenerateIntendedConfigException("Error trying to sync git repository") from exc @@ -268,7 +263,7 @@ def get(self, request, *args, **kwargs): graphql_data=context, ) except Exception as exc: - raise GenerateIntendedConfigException("Error rendering Jinja template") from exc + raise GenerateIntendedConfigException(f"Error rendering Jinja template: {exc}") from exc return Response( data={ "intended_config": intended_config, @@ -315,4 +310,12 @@ def _render_config_nornir_serial(self, device, jinja_template, jinja_root_path, "generate_config", device.platform.network_driver, logging.getLogger(dispatch_params.__module__) ), ) - return results[device.name][1][1][0].result["config"] + if results[device.name].failed: + if results[device.name].exception: # pylint: disable=no-else-raise + raise results[device.name].exception + else: + raise GenerateIntendedConfigException( + f"Error generating intended config for {device.name}: {results[device.name].result}" + ) + else: + return results[device.name][1][1][0].result["config"] diff --git a/nautobot_golden_config/forms.py b/nautobot_golden_config/forms.py index 66559015..1c7a5740 100644 --- a/nautobot_golden_config/forms.py +++ b/nautobot_golden_config/forms.py @@ -598,3 +598,13 @@ class Meta: "change_control_url", "tags", ] + + +class GenerateIntendedConfigForm(django_forms.Form): + """Form for generating intended configuration.""" + + device = forms.DynamicModelChoiceField( + queryset=Device.objects.all(), + required=True, + label="Device", + ) diff --git a/nautobot_golden_config/navigation.py b/nautobot_golden_config/navigation.py index cad08c5c..0b3aa46d 100644 --- a/nautobot_golden_config/navigation.py +++ b/nautobot_golden_config/navigation.py @@ -146,6 +146,17 @@ groups=( NavMenuGroup(name="Manage", weight=100, items=tuple(items_operate)), NavMenuGroup(name="Setup", weight=100, items=tuple(items_setup)), + NavMenuGroup( + name="Tools", + weight=300, + items=( + NavMenuItem( + link="plugins:nautobot_golden_config:generate_intended_config", + name="Generate Intended Config", + permissions=["dcim.view_device", "extras.view_gitrepository"], + ), + ), + ), ), ), ) diff --git a/nautobot_golden_config/templates/nautobot_golden_config/generate_intended_config.html b/nautobot_golden_config/templates/nautobot_golden_config/generate_intended_config.html new file mode 100644 index 00000000..d74e62a2 --- /dev/null +++ b/nautobot_golden_config/templates/nautobot_golden_config/generate_intended_config.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% load form_helpers %} +{% load helpers %} +{% load static %} + +{% block extra_styles %} + +{% endblock extra_styles %} + +{% block content %} +
+
+
+
+
{% block title %}Generate Intended Configuration{% endblock title %}
+
+

+ This tool is intended for template developers. Production configuration generation should be initiated from the + Config Overview page. +

+

+ This will render the configuration for the selected device using Jinja templates from the golden config jinja_repository + Git repository for that device. + This feature allows developers to test their configuration templates without running a full "intended configuration" job. See the + + developing intended configuration templates + documentation for more information. +

+

+ Note: + This will perform a git pull on the golden config Jinja template repository to ensure the latest templates are used. +

+ {% render_field form.device %} + {% render_field form.git_repository %} +
+
+
+ + Cancel +
+
+
+
+
+ Intended Configuration + +
+
+ +
+
+
+
+
+{% endblock content %} + +{% block javascript %} + {{ block.super }} + +{% endblock javascript %} diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index 88ed0d8f..13855679 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -1,6 +1,5 @@ """Unit tests for nautobot_golden_config.""" -import uuid from copy import deepcopy from unittest.mock import patch @@ -431,15 +430,15 @@ def setUpTestData(cls): platform.network_driver = "arista_eos" platform.save() + cls.git_repository = GitRepository.objects.get(name="test-jinja-repo-1") cls.golden_config_setting = GoldenConfigSetting.objects.create( name="GoldenConfigSetting test api generate intended config", slug="goldenconfigsetting-test-api-generate-intended-config", sot_agg_query=GraphQLQuery.objects.get(name="GC-SoTAgg-Query-2"), dynamic_group=cls.dynamic_group, + jinja_repository=cls.git_repository, ) - cls.git_repository = GitRepository.objects.get(name="test-jinja-repo-1") - def _setup_mock_path(self, MockPath): # pylint: disable=invalid-name mock_path_instance = MockPath.return_value mock_path_instance.__str__.return_value = "test.j2" @@ -474,7 +473,7 @@ def _generate_config(task, *args, **kwargs): response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), - data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, + data={"device_id": self.device.pk}, **self.header, ) @@ -499,7 +498,7 @@ def test_generate_intended_config_failures(self, mock_dispatcher, MockPath, mock # test missing query parameters response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), - data={"git_repository_id": self.git_repository.pk}, + data={}, **self.header, ) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) @@ -509,24 +508,12 @@ def test_generate_intended_config_failures(self, mock_dispatcher, MockPath, mock "Parameter device_id is required", ) - response = self.client.get( - reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), - data={"device_id": self.device.pk}, - **self.header, - ) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) - self.assertTrue("detail" in response.data) - self.assertEqual( - response.data["detail"], - "Parameter git_repository_id is required", - ) - # test git repo not present on filesystem mock_path_instance.is_file.return_value = False response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), - data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, + data={"device_id": self.device.pk}, **self.header, ) @@ -556,20 +543,23 @@ def _generate_config(task, *args, **kwargs): response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), - data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, + data={"device_id": self.device.pk}, **self.header, ) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertEqual("Error rendering Jinja template", response.data["detail"]) + self.assertEqual( + response.data["detail"].strip(), + "Error rendering Jinja template: Subtask: GENERATE CONFIG (failed)", + ) # test ensure_git_repository failure mock_ensure_git_repository.side_effect = Exception("Test exception") response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), - data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, + data={"device_id": self.device.pk}, **self.header, ) @@ -577,37 +567,38 @@ def _generate_config(task, *args, **kwargs): self.assertTrue("detail" in response.data) self.assertEqual("Error trying to sync git repository", response.data["detail"]) - # test no sot_agg_query on GoldenConfigSetting - self.golden_config_setting.sot_agg_query = None + # test jinja_repository not set + self.golden_config_setting.jinja_repository = None self.golden_config_setting.save() - response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), - data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, + data={"device_id": self.device.pk}, **self.header, ) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertEqual("Golden Config settings sot_agg_query not set", response.data["detail"]) + self.assertEqual(response.data["detail"], "Golden Config settings jinja_repository not set") + + # test no sot_agg_query on GoldenConfigSetting + self.golden_config_setting.sot_agg_query = None + self.golden_config_setting.save() - # test git_repository instance not found - invalid_uuid = uuid.uuid4() response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), - data={"device_id": self.device.pk, "git_repository_id": invalid_uuid}, + data={"device_id": self.device.pk}, **self.header, ) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertEqual(f"GitRepository with id '{invalid_uuid}' not found", response.data["detail"]) + self.assertEqual("Golden Config settings sot_agg_query not set", response.data["detail"]) # test no GoldenConfigSetting found for device GoldenConfigSetting.objects.all().delete() response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), - data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, + data={"device_id": self.device.pk}, **self.header, ) diff --git a/nautobot_golden_config/urls.py b/nautobot_golden_config/urls.py index f6b55835..c66dd37e 100644 --- a/nautobot_golden_config/urls.py +++ b/nautobot_golden_config/urls.py @@ -23,5 +23,6 @@ urlpatterns = [ path("config-compliance/overview/", views.ConfigComplianceOverview.as_view(), name="configcompliance_overview"), path("config-plan/bulk_deploy/", views.ConfigPlanBulkDeploy.as_view(), name="configplan_bulk-deploy"), + path("generate-intended-config/", views.GenerateIntendedConfigView.as_view(), name="generate_intended_config"), path("docs/", RedirectView.as_view(url=static("nautobot_golden_config/docs/index.html")), name="docs"), ] + router.urls diff --git a/nautobot_golden_config/views.py b/nautobot_golden_config/views.py index 9826a845..024c2044 100644 --- a/nautobot_golden_config/views.py +++ b/nautobot_golden_config/views.py @@ -6,13 +6,14 @@ import yaml from django.contrib import messages +from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, ExpressionWrapper, F, FloatField, Max, Q from django.shortcuts import redirect, render from django.urls import reverse from django.utils.html import format_html from django.utils.timezone import make_aware -from django.views.generic import View +from django.views.generic import TemplateView, View from django_pivot.pivot import pivot from nautobot.apps import views from nautobot.core.views import generic @@ -585,3 +586,16 @@ def post(self, request): **job.job_class.serialize_data(request), ) return redirect(job_result.get_absolute_url()) + + +class GenerateIntendedConfigView(PermissionRequiredMixin, TemplateView): + """View to generate the intended configuration.""" + + template_name = "nautobot_golden_config/generate_intended_config.html" + permission_required = ["dcim.view_device", "extras.view_gitrepository"] + + def get_context_data(self, **kwargs): + """Get the context data for the view.""" + context = super().get_context_data(**kwargs) + context["form"] = forms.GenerateIntendedConfigForm() + return context