From 19cc895cb441f71b294f1411b4337710bf9bde33 Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Mon, 21 Oct 2024 16:49:58 -0700 Subject: [PATCH 01/15] wip add tests --- nautobot_golden_config/api/urls.py | 14 ++- nautobot_golden_config/api/views.py | 52 ++++++++- nautobot_golden_config/models.py | 25 ++++ nautobot_golden_config/tests/test_api.py | 51 ++++++++ nautobot_golden_config/tests/test_models.py | 122 +++++++++++++++----- 5 files changed, 228 insertions(+), 36 deletions(-) diff --git a/nautobot_golden_config/api/urls.py b/nautobot_golden_config/api/urls.py index 0e4334ac..10a9151e 100644 --- a/nautobot_golden_config/api/urls.py +++ b/nautobot_golden_config/api/urls.py @@ -17,11 +17,17 @@ router.register("remediation-setting", views.RemediationSettingViewSet) router.register("config-postprocessing", views.ConfigToPushViewSet) router.register("config-plan", views.ConfigPlanViewSet) -urlpatterns = router.urls -urlpatterns.append( + +urlpatterns = [ path( "sotagg//", views.SOTAggDeviceDetailView.as_view(), name="device_detail", - ) -) + ), + path( + "generate-intended-config//", + views.GenerateIntendedConfigView.as_view(), + name="generate_intended_config", + ), +] +urlpatterns += router.urls diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py index 1a7910c9..b6d6f80c 100644 --- a/nautobot_golden_config/api/views.py +++ b/nautobot_golden_config/api/views.py @@ -1,8 +1,10 @@ """View for Golden Config APIs.""" import json +from pathlib import Path from django.contrib.contenttypes.models import ContentType +from nautobot.apps.utils import render_jinja2 from nautobot.core.api.views import ( BulkDestroyModelMixin, BulkUpdateModelMixin, @@ -11,7 +13,10 @@ ) from nautobot.dcim.models import Device from nautobot.extras.api.views import NautobotModelViewSet, NotesViewSetMixin -from rest_framework import mixins, viewsets +from nautobot.extras.datasources.git import ensure_git_repository +from rest_framework import mixins, status, viewsets +from rest_framework.exceptions import APIException +from rest_framework.generics import RetrieveAPIView from rest_framework.mixins import DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin from rest_framework.permissions import AllowAny, BasePermission, IsAuthenticated from rest_framework.response import Response @@ -161,3 +166,48 @@ def get_serializer_context(self): } ) return context + + +class GenerateIntendedConfigException(APIException): + """Exception for when the intended config cannot be generated.""" + + status_code = 400 + default_detail = "Unable to generate the intended config for this device." + default_code = "error" + + +class GenerateIntendedConfigView(RetrieveAPIView): + """API view for generating the intended config for a Device.""" + + queryset = Device.objects.all() + name = "Generate Intended Config" + + def retrieve(self, request, *args, **kwargs): + """Retrieve intended configuration for a Device.""" + device = self.get_object() + settings = models.GoldenConfigSetting.objects.get_for_device(device) + if not settings: + raise GenerateIntendedConfigException("No Golden Config settings found for this device.") + if not settings.jinja_repository: + raise GenerateIntendedConfigException("Golden Config jinja template repository not found.") + if not settings.sot_agg_query: + raise GenerateIntendedConfigException("Golden Config GraphQL query not found.") + + ensure_git_repository(settings.jinja_repository) + filesystem_path = settings.get_jinja_template_path_for_device(device) + if not Path(filesystem_path).is_file(): + raise GenerateIntendedConfigException("Jinja template not found for this device.") + + status_code, context = graph_ql_query(request, device, settings.sot_agg_query.query) + if status_code == status.HTTP_200_OK: + template_contents = Path(filesystem_path).read_text() + intended_config = render_jinja2(template_code=template_contents, context=context) + return Response( + data={ + "intended_config": intended_config, + "intended_config_lines": intended_config.split("\n"), + }, + status=status.HTTP_200_OK, + ) + + raise GenerateIntendedConfigException("Unable to generate the intended config for this device.") diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index 16bc5ae7..50fd9ef3 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -2,12 +2,16 @@ import json import logging +import os from deepdiff import DeepDiff from django.core.exceptions import ValidationError from django.db import models +from django.db.models.manager import BaseManager from django.utils.module_loading import import_string from hier_config import Host as HierConfigHost +from nautobot.apps.models import RestrictedQuerySet +from nautobot.apps.utils import render_jinja2 from nautobot.core.models.generics import PrimaryModel from nautobot.core.models.utils import serialize_object, serialize_object_v2 from nautobot.dcim.models import Device @@ -497,6 +501,19 @@ def __str__(self): return f"{self.device}" +class GoldenConfigSettingManager(BaseManager.from_queryset(RestrictedQuerySet)): + """Manager for GoldenConfigSetting.""" + + def get_for_device(self, device): + """Return the highest weighted GoldenConfigSetting assigned to a device.""" + if not isinstance(device, Device): + raise ValueError("The device argument must be a Device instance.") + dynamic_group = device.dynamic_groups.exclude(golden_config_setting__isnull=True) + if dynamic_group.exists(): + return dynamic_group.order_by("-golden_config_setting__weight").first().golden_config_setting + return None + + @extras_features( "graphql", ) @@ -570,6 +587,8 @@ class GoldenConfigSetting(PrimaryModel): # pylint: disable=too-many-ancestors related_name="golden_config_setting", ) + objects = GoldenConfigSettingManager() + def __str__(self): """Return a simple string if model is called.""" return f"Golden Config Setting - {self.name}" @@ -609,6 +628,12 @@ def get_url_to_filtered_device_list(self): """Get url to all devices that are matching the filter.""" return self.dynamic_group.get_group_members_url() + def get_jinja_template_path_for_device(self, device): + """Get the Jinja template path for a device.""" + if self.jinja_repository is not None: + rendered_path = render_jinja2(template_code=self.jinja_path_template, context={"obj": device}) + return f"{self.jinja_repository.filesystem_path}{os.path.sep}{rendered_path}" + @extras_features( "custom_fields", diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index 1e7e6b7d..0d3a0651 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -1,6 +1,7 @@ """Unit tests for nautobot_golden_config.""" from copy import deepcopy +from unittest.mock import patch from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType @@ -406,3 +407,53 @@ def setUpTestData(cls): "change_control_url": "https://5.example.com/", "status": approved_status.pk, } + + +class GenerateIntendedConfigViewAPITestCase(APIViewTestCases.GetObjectViewTestCase): + """Test API for GenerateIntendedConfigView.""" + + model = Device + + @classmethod + def setUpTestData(cls): + create_device_data() + create_git_repos() + create_saved_queries() + + cls.dynamic_group = DynamicGroup.objects.create( + name="all devices dg", + content_type=ContentType.objects.get_for_model(Device), + ) + + cls.device = Device.objects.get(name="Device 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"), + jinja_repository=GitRepository.objects.get(name="test-jinja-repo-1"), + dynamic_group=cls.dynamic_group, + ) + + @patch("nautobot_golden_config.api.views.ensure_git_repository") + @patch("nautobot_golden_config.api.views.Path") + def test_generate_intended_config(self, MockPath, mock_ensure_git_repository): + """Verify that the intended config is generated as expected.""" + + self.add_permissions("dcim.view_device") + + MockPathInstance = MockPath.return_value + MockPathInstance.is_file.return_value = True + MockPathInstance.read_text.return_value = r"Jinja test for device {{ name }}." + + response = self.client.get( + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config", kwargs={"pk": self.device.pk}), + **self.header, + ) + + mock_ensure_git_repository.assert_called_once_with(self.golden_config_setting.jinja_repository) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertTrue("intended_config" in response.data) + self.assertTrue("intended_config_lines" in response.data) + self.assertEqual(response.data["intended_config"], f"Jinja test for device {self.device.name}.") + self.assertEqual(response.data["intended_config_lines"], [f"Jinja test for device {self.device.name}."]) diff --git a/nautobot_golden_config/tests/test_models.py b/nautobot_golden_config/tests/test_models.py index cfcaa408..ea529973 100644 --- a/nautobot_golden_config/tests/test_models.py +++ b/nautobot_golden_config/tests/test_models.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models.deletion import ProtectedError -from django.test import TestCase +from nautobot.core.testing import TestCase from nautobot.dcim.models import Platform from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, Status @@ -32,12 +32,13 @@ class ConfigComplianceModelTestCase(TestCase): """Test CRUD operations for ConfigCompliance Model.""" - def setUp(self): + @classmethod + def setUpTestData(cls): """Set up base objects.""" - self.device = create_device() - self.compliance_rule_json = create_feature_rule_json(self.device) - self.compliance_rule_xml = create_feature_rule_xml(self.device) - self.compliance_rule_cli = create_feature_rule_cli_with_remediation(self.device) + cls.device = create_device() + cls.compliance_rule_json = create_feature_rule_json(cls.device) + cls.compliance_rule_xml = create_feature_rule_xml(cls.device) + cls.compliance_rule_cli = create_feature_rule_cli_with_remediation(cls.device) def test_create_config_compliance_success_json(self): """Successful.""" @@ -190,21 +191,22 @@ class ComplianceRuleTestCase(TestCase): class GoldenConfigSettingModelTestCase(TestCase): """Test GoldenConfigSetting Model.""" - def setUp(self): + @classmethod + def setUpTestData(cls): """Get the golden config settings with the only allowed id.""" create_git_repos() create_saved_queries() # Since we enforce a singleton pattern on this model, nuke the auto-created object. GoldenConfigSetting.objects.all().delete() - content_type = ContentType.objects.get(app_label="dcim", model="device") - dynamic_group = DynamicGroup.objects.create( + cls.device_content_type = ContentType.objects.get(app_label="dcim", model="device") + cls.dynamic_group = DynamicGroup.objects.create( name="test1 site site-4", - content_type=content_type, + content_type=cls.device_content_type, filter={}, ) - self.global_settings = GoldenConfigSetting.objects.create( # pylint: disable=attribute-defined-outside-init + cls.global_settings = GoldenConfigSetting.objects.create( # pylint: disable=attribute-defined-outside-init name="test", slug="test", weight=1000, @@ -216,7 +218,7 @@ def setUp(self): jinja_path_template="{{ obj.platform.name }}/main.j2", backup_repository=GitRepository.objects.get(name="test-backup-repo-1"), intended_repository=GitRepository.objects.get(name="test-intended-repo-1"), - dynamic_group=dynamic_group, + dynamic_group=cls.dynamic_group, ) def test_absolute_url_success(self): @@ -237,11 +239,65 @@ def test_good_graphql_query_validate_starts_with(self): self.global_settings.sot_agg_query = GraphQLQuery.objects.get(name="GC-SoTAgg-Query-1") self.assertEqual(self.global_settings.clean(), None) + def test_get_for_device(self): + """Test get_for_device method on GoldenConfigSettingManager.""" + device = create_device() + + # test that the highest weight GoldenConfigSetting is returned + other_dynamic_group = DynamicGroup.objects.create( + name="test get_for_device dg", + content_type=self.device_content_type, + filter={"name": [device.name]}, + ) + other_dynamic_group.update_cached_members() + other_settings = GoldenConfigSetting.objects.create( + name="test other", + slug="testother", + weight=100, + description="Test Description.", + backup_path_template="{{ obj.location.parant.name }}/{{obj.name}}.cfg", + intended_path_template="{{ obj.location.name }}/{{ obj.name }}.cfg", + backup_test_connectivity=True, + jinja_repository=GitRepository.objects.get(name="test-jinja-repo-1"), + jinja_path_template="{{ obj.platform.name }}/main.j2", + backup_repository=GitRepository.objects.get(name="test-backup-repo-1"), + intended_repository=GitRepository.objects.get(name="test-intended-repo-1"), + dynamic_group=other_dynamic_group, + ) + + self.dynamic_group.update_cached_members() + self.assertEqual(GoldenConfigSetting.objects.get_for_device(device), self.global_settings) + + other_settings.weight = 2000 + other_settings.save() + self.assertEqual(GoldenConfigSetting.objects.get_for_device(device), other_settings) + + # test that no GoldenConfigSetting is returned when the device is not in the dynamic group + self.dynamic_group.filter = {"name": [f"{device.name} nomatch"]} + other_dynamic_group.filter = {"name": [f"{device.name} nomatch"]} + self.dynamic_group.save() + other_dynamic_group.save() + self.dynamic_group.update_cached_members() + other_dynamic_group.update_cached_members() + self.assertIsNone(GoldenConfigSetting.objects.get_for_device(device)) + + def test_get_jinja_template_path_for_device(self): + """Test get_jinja_template_path_for_device method on GoldenConfigSetting.""" + device = create_device() + self.assertEqual( + self.global_settings.get_jinja_template_path_for_device(device), + f"{self.global_settings.jinja_repository.filesystem_path}/Platform 1/main.j2", + ) + self.global_settings.jinja_repository = None + self.global_settings.save() + self.assertIsNone(self.global_settings.get_jinja_template_path_for_device(device)) + class GoldenConfigSettingGitModelTestCase(TestCase): """Test GoldenConfigSetting Model.""" - def setUp(self) -> None: + @classmethod + def setUpTestData(cls) -> None: """Setup test data.""" create_git_repos() @@ -255,7 +311,7 @@ def setUp(self) -> None: ) # Create fresh new object, populate accordingly. - self.golden_config = GoldenConfigSetting.objects.create( # pylint: disable=attribute-defined-outside-init + cls.golden_config = GoldenConfigSetting.objects.create( # pylint: disable=attribute-defined-outside-init name="test", slug="test", weight=1000, @@ -298,11 +354,12 @@ def test_clean_up(self): class ConfigRemoveModelTestCase(TestCase): """Test ConfigRemove Model.""" - def setUp(self): + @classmethod + def setUpTestData(cls): """Setup Object.""" - self.platform = Platform.objects.create(name="Cisco IOS", network_driver="cisco_ios") - self.line_removal = ConfigRemove.objects.create( - name="foo", platform=self.platform, description="foo bar", regex="^Back.*" + cls.platform = Platform.objects.create(name="Cisco IOS", network_driver="cisco_ios") + cls.line_removal = ConfigRemove.objects.create( + name="foo", platform=cls.platform, description="foo bar", regex="^Back.*" ) def test_add_line_removal_entry(self): @@ -329,12 +386,13 @@ def test_edit_line_removal_entry(self): class ConfigReplaceModelTestCase(TestCase): """Test ConfigReplace Model.""" - def setUp(self): + @classmethod + def setUpTestData(cls): """Setup Object.""" - self.platform = Platform.objects.create(name="Cisco IOS", network_driver="cisco_ios") - self.line_replace = ConfigReplace.objects.create( + cls.platform = Platform.objects.create(name="Cisco IOS", network_driver="cisco_ios") + cls.line_replace = ConfigReplace.objects.create( name="foo", - platform=self.platform, + platform=cls.platform, description="foo bar", regex=r"username(\S+)", replace="", @@ -366,13 +424,14 @@ def test_edit_line_replace_entry(self): class ConfigPlanModelTestCase(TestCase): """Test ConfigPlan Model.""" - def setUp(self): + @classmethod + def setUpTestData(cls): """Setup Object.""" - self.device = create_device() - self.rule = create_feature_rule_json(self.device) - self.feature = self.rule.feature - self.status = Status.objects.get(name="Not Approved") - self.job_result = create_job_result() + cls.device = create_device() + cls.rule = create_feature_rule_json(cls.device) + cls.feature = cls.rule.feature + cls.status = Status.objects.get(name="Not Approved") + cls.job_result = create_job_result() def test_create_config_plan_intended(self): """Test Create Object.""" @@ -472,10 +531,11 @@ def test_create_config_plan_manual(self): class RemediationSettingModelTestCase(TestCase): """Test Remediation Setting Model.""" - def setUp(self): + @classmethod + def setUpTestData(cls): """Setup Object.""" - self.platform = Platform.objects.create(name="Cisco IOS", network_driver="cisco_ios") - self.remediation_options = { + cls.platform = Platform.objects.create(name="Cisco IOS", network_driver="cisco_ios") + cls.remediation_options = { "optionA": "someValue", "optionB": "someotherValue", "optionC": "anotherValue", From 03105c548fe27c929931dac81d7f4d6a75fdcbe7 Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Wed, 23 Oct 2024 10:16:21 -0700 Subject: [PATCH 02/15] update post processing documentation --- .../user/app_feature_config_postprocessing.md | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/user/app_feature_config_postprocessing.md b/docs/user/app_feature_config_postprocessing.md index 6e041712..bdcefa71 100644 --- a/docs/user/app_feature_config_postprocessing.md +++ b/docs/user/app_feature_config_postprocessing.md @@ -1,64 +1,64 @@ # Navigating Configuration Post-processing !!! note - Current implementation **only renders the configuration to push, it doesn't update the configuration** into the target devices. + The current implementation **only renders the configuration for pushing and does not update the configuration** on the target devices. -The intended configuration job doesn't produce a final configuration artifact (see below for reasons why). The intended configuration is the "intended" **running** configuration, because the intended configuration job generates what is in the final running configuration. This works well for the "compliance" feature, but not as well to create a configuration artifact that is ready to push. +The intended configuration job doesn't produce a final configuration artifact (see below for reasons). The intended configuration represents the "intended" **running** configuration, as it generates what is expected to be in the final running configuration. While this approach works well for the "compliance" feature, it is less effective for creating a configuration artifact that is ready to be pushed to devices. -Challenging use cases when using the running configuration as intended: +Challenges when using the running configuration as the intended configuration: -- Because the intended configuration is stored in the database, and in an external Git repository, it should **not** contain any secret. -- The format of the running configuration is not always the same as the configuration to push, examples include: - - Pushing SNMPv3 configurations, which do not show up in the running config - - VTP configurations where the configurations is not in the running config at all - - Implicit configurations like a "no shutdown" on an interface -- The configurations used to get the configuration to the intended state may require to be ordered to not cause an outage. +- Since the intended configuration is stored in both the database and an external Git repository, it should **not** contain any secrets. +- The format of the running configuration may differ from the configuration that needs to be pushed. Examples include: + - SNMPv3 configurations, which do not appear in the running configuration + - VTP configurations that are entirely absent from the running configuration + - Implicit configurations, such as "no shutdown" commands on interfaces +- Configurations necessary to achieve the intended state may need to be ordered carefully to prevent outages. -As the Golden Config application becomes more mature in delivering an all encompassing configuration management solution, it requires an advanced feature to render a configuration artifact. That artifact must be in the final format your device is expecting, from the intended configuration. +As the Golden Config application evolves into a comprehensive configuration management solution, it requires an advanced feature to generate a configuration artifact that is in the final format expected by your device, based on the intended configuration. -This is exposed via the `get_config_postprocessing()` function defined in `nautobot_golden_config.utilities.config_postprocessing`. This method takes the current configurations generated by the Golden Config intended configuration feature, and the HTTP request. This function will return the intended configuration that is **ready to push**. +This is achieved through the `get_config_postprocessing()` function defined in `nautobot_golden_config.utilities.config_postprocessing`. This method processes the configurations generated by the Golden Config intended configuration feature, along with the HTTP request. It returns the intended configuration that is **ready to be pushed**. -From the user perspective, you can retrieve this configuration via two methods: +From a user perspective, you can retrieve this configuration using two methods: -- UI: within the `Device` detail view, if the feature is enabled, a new row in the "Configuration Types" appears, and clicking the icon the new configuration will be rendered on the fly (synchronously). Check figure. -- REST API: at the path `/api/plugins/golden-config/config-postprocessing/{device_id}` you can request the intended configuration processed, and the return payload will contain a "config" key with the rendered configuration. +- **UI**: In the `Device` detail view, if the feature is enabled, a new row appears under "Configuration Types." Clicking the icon renders the new configuration on the fly (synchronously). See the figure below for reference. +- **REST API**: You can request the processed intended configuration at the path `/api/plugins/golden-config/config-postprocessing/{device_id}`. The return payload will contain a "config" key with the rendered configuration. ![Configuration Postprocessing](../images/config_postprocessing_1.png) -## Customize Configuration Processing +## Customizing Configuration Processing -There are two different ways to customize the default behavior of `get_config_postprocessing` method: +There are two ways to customize the default behavior of the `get_config_postprocessing` method: -- `postprocessing_callables`: is the list of **available methods** for processing the intended configuration. It contains some default implemented methods, currently `render_secrets`. But it could be extended via configuration options (see next section). The format for defining these methods is via the dotted string format that will be imported by Django. For example, the `render_secrets` is defined as `"nautobot_golden_config.utilities.config_postprocessing.render_secrets"`. -- `postprocessing_subscribed`: is the list of **methods names** (strings) that define the **order** in the processing chain. The defined methods MUST exist in the `postprocessing_callables` list. This list can be customized via configuration options, and eventually, it could be extended to accept HTTP query parameters. +- `postprocessing_callables`: A list of **available methods** for processing the intended configuration. It includes some default methods, such as `render_secrets`, but can be extended via configuration options (see the next section). These methods are defined using a dotted string format that Django imports. For example, `render_secrets` is defined as `"nautobot_golden_config.utilities.config_postprocessing.render_secrets"`. +- `postprocessing_subscribed`: A list of **method names** (strings) that define the **order** in which methods are executed. The methods must exist in the `postprocessing_callables` list. This list can be customized through configuration options and could eventually accept HTTP query parameters for further customization. -## Existing Default Processors +## Default Processors ### Render Secrets -The `render_secrets` function performs an extra Jinja rendering on top of an intended configuration, exposing new custom Jinja filters: +The `render_secrets` function performs an additional Jinja rendering on the intended configuration, providing custom Jinja filters: -- `get_secret_by_secret_group_name`: as the name suggests, it returns the secret_group value, for a secret type, from its `name`. +- `get_secret_by_secret_group_name`: As the name implies, this filter returns the value of a secret group for a given secret type based on its `name`. !!! note - Other default Django or Netutils filters are not available in this Jinja environment. Only `encrypt__type5` and `encrypt__type7` can be used together with the `get_secret` filters. + Standard Django or Netutils filters are not available in this Jinja environment. Only `encrypt__type5` and `encrypt__type7` filters can be used in conjunction with the `get_secret` filters. -Because this rendering is separated from the standard generation of the intended configuration, you must use the `{% raw %}` Jinja syntax to avoid being processed by the initial generation stage. +Since this rendering occurs after the initial generation of the intended configuration, the `{% raw %}` Jinja syntax must be used to prevent premature processing. -1. For example, an original template like this, `{% raw %}ppp pap sent-username {{ secrets_group["name"] | get_secret_by_secret_group_name("username")}}{% endraw %}` -2. Produces an intended configuration as `ppp pap sent-username {{ secrets_group["name"] | get_secret_by_secret_group_name("username") }}` -3. After the `render_secrets`, it becomes `ppp pap sent-username my_username`. +1. For example, an original template might look like this: `{% raw %}ppp pap sent-username {{ secrets_group["name"] | get_secret_by_secret_group_name("username") }}{% endraw %}` +2. It produces an intended configuration like this: `ppp pap sent-username {{ secrets_group["name"] | get_secret_by_secret_group_name("username") }}` +3. After applying `render_secrets`, it becomes: `ppp pap sent-username my_username`. -Notice that the `get_secret` filters take arguments. In the example, the `secret_group` name is passed, together with the type of the `Secret`. Check every signature for extra customization. +Note that the `get_secret` filters accept arguments. In the example, the `secret_group` name is passed along with the type of secret. You can customize the signature for additional options. !!! note - Remember that to render these secrets, the user requesting it via UI or API, MUST have read permissions to Secrets Groups, Golden Config, and the specific Device object. + To render secrets, the user requesting the configuration via UI or API **must** have read permissions for Secrets Groups, Golden Config, and the specific Device object. #### Render Secrets Example -This shows how Render the Secrets feature for a `Device`, for the default `Secrets Group` FK, and for custom relationships, in the example, at `Location` level. +Here is an example of rendering secrets for a `Device`, using the default `Secrets Group` ForeignKey (FK) and custom relationships, in this case at the `Location` level. -##### GraphQL query +##### GraphQL Query ```graphql query ($device_id: ID!) { @@ -89,11 +89,11 @@ Using the custom relationship at the `Location` level: {% raw %}{{ location["rel_my_secret_relationship_for_location"][0]["name"] | get_secret_by_secret_group_name("password") | default('no password') }}{% endraw %} ``` -This will end up rendering the secret, of type "password", for the corresponding `SecretGroup`. +This will render the secret of type "password" for the corresponding `SecretGroup`. -##### Managing errors +##### Managing Errors -Obviously, the rendering process can find multiple challenges, that are managed, and properly explained to take corrective actions: +The rendering process may encounter issues, which are managed and properly explained to guide corrective actions: ``` Found an error rendering the configuration to push: Jinja encountered and UndefinedError: 'None' has no attribute 'name', check the template for missing variable definitions. From 0697a522b0a3ab8e6d82a2d716b73b1f58de0c3a Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:12:41 -0700 Subject: [PATCH 03/15] add more error checking and tests --- nautobot_golden_config/api/views.py | 11 +++- nautobot_golden_config/tests/test_api.py | 83 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py index b6d6f80c..a08a0697 100644 --- a/nautobot_golden_config/api/views.py +++ b/nautobot_golden_config/api/views.py @@ -4,6 +4,7 @@ from pathlib import Path from django.contrib.contenttypes.models import ContentType +from jinja2.exceptions import TemplateError, TemplateSyntaxError from nautobot.apps.utils import render_jinja2 from nautobot.core.api.views import ( BulkDestroyModelMixin, @@ -193,7 +194,10 @@ def retrieve(self, request, *args, **kwargs): if not settings.sot_agg_query: raise GenerateIntendedConfigException("Golden Config GraphQL query not found.") - ensure_git_repository(settings.jinja_repository) + try: + ensure_git_repository(settings.jinja_repository) + except Exception as exc: + raise GenerateIntendedConfigException(f"Error trying to sync Jinja template repository: {exc}") filesystem_path = settings.get_jinja_template_path_for_device(device) if not Path(filesystem_path).is_file(): raise GenerateIntendedConfigException("Jinja template not found for this device.") @@ -201,7 +205,10 @@ def retrieve(self, request, *args, **kwargs): status_code, context = graph_ql_query(request, device, settings.sot_agg_query.query) if status_code == status.HTTP_200_OK: template_contents = Path(filesystem_path).read_text() - intended_config = render_jinja2(template_code=template_contents, context=context) + try: + intended_config = render_jinja2(template_code=template_contents, context=context) + except (TemplateSyntaxError, TemplateError) as exc: + raise GenerateIntendedConfigException(f"Error rendering Jinja template: {exc}") return Response( data={ "intended_config": intended_config, diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index 0d3a0651..8e4a7e91 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -457,3 +457,86 @@ def test_generate_intended_config(self, MockPath, mock_ensure_git_repository): self.assertTrue("intended_config_lines" in response.data) self.assertEqual(response.data["intended_config"], f"Jinja test for device {self.device.name}.") self.assertEqual(response.data["intended_config_lines"], [f"Jinja test for device {self.device.name}."]) + + @patch("nautobot_golden_config.api.views.ensure_git_repository") + @patch("nautobot_golden_config.api.views.Path") + def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repository): + """Verify that errors are handled as expected.""" + + self.add_permissions("dcim.view_device") + MockPathInstance = MockPath.return_value + + # test git repo not found + MockPathInstance.is_file.return_value = False + + response = self.client.get( + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config", kwargs={"pk": self.device.pk}), + **self.header, + ) + + mock_ensure_git_repository.assert_called_once_with(self.golden_config_setting.jinja_repository) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertTrue("detail" in response.data) + self.assertEqual(response.data["detail"], "Jinja template not found for this device.") + + # test invalid jinja template + MockPathInstance.is_file.return_value = True + MockPathInstance.read_text.return_value = r"Jinja test for device {{ name }." + + response = self.client.get( + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config", kwargs={"pk": self.device.pk}), + **self.header, + ) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertTrue("detail" in response.data) + self.assertIn("Error rendering Jinja template:", response.data["detail"]) + + # 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", kwargs={"pk": self.device.pk}), + **self.header, + ) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertTrue("detail" in response.data) + self.assertIn("Error trying to sync Jinja template repository:", response.data["detail"]) + + # test no sot_agg_query on GoldenConfigSetting + self.golden_config_setting.sot_agg_query = None + self.golden_config_setting.save() + + response = self.client.get( + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config", kwargs={"pk": self.device.pk}), + **self.header, + ) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertTrue("detail" in response.data) + self.assertIn("Golden Config GraphQL query not found.", response.data["detail"]) + + # test no jinja_repository on GoldenConfigSetting + 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", kwargs={"pk": self.device.pk}), + **self.header, + ) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertTrue("detail" in response.data) + self.assertIn("Golden Config jinja template repository not found.", 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", kwargs={"pk": self.device.pk}), + **self.header, + ) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertTrue("detail" in response.data) + self.assertIn("No Golden Config settings found for this device.", response.data["detail"]) From 63599594795fe8ddba66c28cc902d5069ba13edb Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:14:02 -0700 Subject: [PATCH 04/15] fix incorrect test base class. change test setup for a few tests --- .../forms/test_golden_config_settings.py | 3 +- nautobot_golden_config/tests/test_filters.py | 62 ++++++++++--------- .../tests/test_utilities/test_config_plan.py | 5 +- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/nautobot_golden_config/tests/forms/test_golden_config_settings.py b/nautobot_golden_config/tests/forms/test_golden_config_settings.py index 8f2719a1..67ec5696 100644 --- a/nautobot_golden_config/tests/forms/test_golden_config_settings.py +++ b/nautobot_golden_config/tests/forms/test_golden_config_settings.py @@ -13,7 +13,8 @@ class GoldenConfigSettingFormTest(TestCase): """Test Golden Config Setting Feature Form.""" - def setUp(self) -> None: + @classmethod + def setUpTestData(cls) -> None: """Setup test data.""" create_git_repos() create_device_data() diff --git a/nautobot_golden_config/tests/test_filters.py b/nautobot_golden_config/tests/test_filters.py index 41f506d5..b4abe7a4 100644 --- a/nautobot_golden_config/tests/test_filters.py +++ b/nautobot_golden_config/tests/test_filters.py @@ -17,26 +17,27 @@ class ConfigComplianceModelTestCase(TestCase): # pylint: disable=too-many-publi queryset = models.ConfigCompliance.objects.all() filterset = filters.ConfigComplianceFilterSet - def setUp(self): + @classmethod + def setUpTestData(cls): """Set up base objects.""" create_device_data() - self.dev01 = Device.objects.get(name="Device 1") + cls.dev01 = Device.objects.get(name="Device 1") dev02 = Device.objects.get(name="Device 2") - self.dev03 = Device.objects.get(name="Device 3") + cls.dev03 = Device.objects.get(name="Device 3") dev04 = Device.objects.get(name="Device 4") dev05 = Device.objects.get(name="Device 5") dev06 = Device.objects.get(name="Device 6") - feature_dev01 = create_feature_rule_json(self.dev01) + feature_dev01 = create_feature_rule_json(cls.dev01) feature_dev02 = create_feature_rule_json(dev02) - feature_dev03 = create_feature_rule_json(self.dev03) + feature_dev03 = create_feature_rule_json(cls.dev03) feature_dev05 = create_feature_rule_json(dev05, feature="baz") feature_dev06 = create_feature_rule_json(dev06, feature="bar") updates = [ - {"device": self.dev01, "feature": feature_dev01}, + {"device": cls.dev01, "feature": feature_dev01}, {"device": dev02, "feature": feature_dev02}, - {"device": self.dev03, "feature": feature_dev03}, + {"device": cls.dev03, "feature": feature_dev03}, {"device": dev04, "feature": feature_dev01}, {"device": dev05, "feature": feature_dev05}, {"device": dev06, "feature": feature_dev06}, @@ -227,17 +228,18 @@ class GoldenConfigModelTestCase(ConfigComplianceModelTestCase): queryset = models.GoldenConfig.objects.all() filterset = filters.GoldenConfigFilterSet - def setUp(self): + @classmethod + def setUpTestData(cls): """Set up base objects.""" create_device_data() - self.dev01 = Device.objects.get(name="Device 1") + cls.dev01 = Device.objects.get(name="Device 1") dev02 = Device.objects.get(name="Device 2") - self.dev03 = Device.objects.get(name="Device 3") + cls.dev03 = Device.objects.get(name="Device 3") dev04 = Device.objects.get(name="Device 4") dev05 = Device.objects.get(name="Device 5") dev06 = Device.objects.get(name="Device 6") - updates = [self.dev01, dev02, self.dev03, dev04, dev05, dev06] + updates = [cls.dev01, dev02, cls.dev03, dev04, dev05, dev06] for update in updates: models.GoldenConfig.objects.create( device=update, @@ -250,15 +252,16 @@ class ConfigRemoveModelTestCase(TestCase): queryset = models.ConfigRemove.objects.all() filterset = filters.ConfigRemoveFilterSet - def setUp(self): + @classmethod + def setUpTestData(cls): """Setup Object.""" - self.platform1 = Platform.objects.create(name="Platform 1") + cls.platform1 = Platform.objects.create(name="Platform 1") platform2 = Platform.objects.create(name="Platform 2") - self.obj1 = models.ConfigRemove.objects.create( - name="Remove 1", platform=self.platform1, description="Description 1", regex="^Remove 1" + cls.obj1 = models.ConfigRemove.objects.create( + name="Remove 1", platform=cls.platform1, description="Description 1", regex="^Remove 1" ) models.ConfigRemove.objects.create( - name="Remove 2", platform=self.platform1, description="Description 2", regex="^Remove 2" + name="Remove 2", platform=cls.platform1, description="Description 2", regex="^Remove 2" ) models.ConfigRemove.objects.create( name="Remove 3", platform=platform2, description="Description 3", regex="^Remove 3" @@ -297,20 +300,21 @@ class ConfigReplaceModelTestCase(ConfigRemoveModelTestCase): queryset = models.ConfigReplace.objects.all() filterset = filters.ConfigReplaceFilterSet - def setUp(self): + @classmethod + def setUpTestData(cls): """Setup Object.""" - self.platform1 = Platform.objects.create(name="Platform 1") + cls.platform1 = Platform.objects.create(name="Platform 1") platform2 = Platform.objects.create(name="Platform 2") - self.obj1 = models.ConfigReplace.objects.create( + cls.obj1 = models.ConfigReplace.objects.create( name="Remove 1", - platform=self.platform1, + platform=cls.platform1, description="Description 1", regex="^Remove 1", replace="Replace 1", ) models.ConfigReplace.objects.create( name="Remove 2", - platform=self.platform1, + platform=cls.platform1, description="Description 2", regex="^Remove 2", replace="Replace 2", @@ -326,17 +330,18 @@ class ComplianceRuleModelTestCase(ConfigRemoveModelTestCase): queryset = models.ComplianceRule.objects.all() filterset = filters.ComplianceRuleFilterSet - def setUp(self): + @classmethod + def setUpTestData(cls): """Setup Object.""" - self.platform1 = Platform.objects.create(name="Platform 1") + cls.platform1 = Platform.objects.create(name="Platform 1") platform2 = Platform.objects.create(name="Platform 2") feature1 = models.ComplianceFeature.objects.create(name="Feature 1", slug="feature-1") feature2 = models.ComplianceFeature.objects.create(name="Feature 2", slug="feature-2") - self.obj1 = models.ComplianceRule.objects.create( - platform=self.platform1, feature=feature1, config_type="cli", config_ordered=True, match_config="config 1" + cls.obj1 = models.ComplianceRule.objects.create( + platform=cls.platform1, feature=feature1, config_type="cli", config_ordered=True, match_config="config 1" ) models.ComplianceRule.objects.create( - platform=self.platform1, feature=feature2, config_type="cli", config_ordered=True, match_config="config 2" + platform=cls.platform1, feature=feature2, config_type="cli", config_ordered=True, match_config="config 2" ) models.ComplianceRule.objects.create( platform=platform2, feature=feature1, config_type="cli", config_ordered=True, match_config="config 3" @@ -357,9 +362,10 @@ class ComplianceFeatureModelTestCase(TestCase): queryset = models.ComplianceFeature.objects.all() filterset = filters.ComplianceFeatureFilterSet - def setUp(self): + @classmethod + def setUpTestData(cls): """Setup Object.""" - self.obj1 = models.ComplianceFeature.objects.create(name="Feature 1", slug="feature-1") + cls.obj1 = models.ComplianceFeature.objects.create(name="Feature 1", slug="feature-1") models.ComplianceFeature.objects.create(name="Feature 2", slug="feature-2") models.ComplianceFeature.objects.create(name="Feature 3", slug="feature-3") diff --git a/nautobot_golden_config/tests/test_utilities/test_config_plan.py b/nautobot_golden_config/tests/test_utilities/test_config_plan.py index b83fb5b2..95c813a0 100644 --- a/nautobot_golden_config/tests/test_utilities/test_config_plan.py +++ b/nautobot_golden_config/tests/test_utilities/test_config_plan.py @@ -1,8 +1,9 @@ """Unit tests for the nautobot_golden_config utilities config_plan.""" -import unittest from unittest.mock import Mock, patch +from nautobot.core.testing import TestCase + from nautobot_golden_config.tests.conftest import create_config_compliance, create_device, create_feature_rule_cli from nautobot_golden_config.utilities.config_plan import ( config_plan_default_status, @@ -11,7 +12,7 @@ ) -class ConfigPlanTest(unittest.TestCase): +class ConfigPlanTest(TestCase): """Test Config Plan Utility.""" def setUp(self): From 1bbb42f4a4db80bb9fe26479b25f80f6762174fd Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Wed, 23 Oct 2024 15:45:02 -0700 Subject: [PATCH 05/15] fix imports --- nautobot_golden_config/tests/test_models.py | 2 +- nautobot_golden_config/tests/test_utilities/test_config_plan.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nautobot_golden_config/tests/test_models.py b/nautobot_golden_config/tests/test_models.py index ea529973..c6ee4f44 100644 --- a/nautobot_golden_config/tests/test_models.py +++ b/nautobot_golden_config/tests/test_models.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models.deletion import ProtectedError -from nautobot.core.testing import TestCase +from nautobot.apps.testing import TestCase from nautobot.dcim.models import Platform from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, Status diff --git a/nautobot_golden_config/tests/test_utilities/test_config_plan.py b/nautobot_golden_config/tests/test_utilities/test_config_plan.py index 95c813a0..f0e12b65 100644 --- a/nautobot_golden_config/tests/test_utilities/test_config_plan.py +++ b/nautobot_golden_config/tests/test_utilities/test_config_plan.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, patch -from nautobot.core.testing import TestCase +from nautobot.apps.testing import TestCase from nautobot_golden_config.tests.conftest import create_config_compliance, create_device, create_feature_rule_cli from nautobot_golden_config.utilities.config_plan import ( From e29bafbb4025d36c27a41ccaf8f7e62976b83e1e Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Thu, 24 Oct 2024 13:45:32 -0700 Subject: [PATCH 06/15] add git repository query parameter, change url param to query param for device, update tests, update docs --- docs/user/app_feature_intended.md | 21 ++++++ nautobot_golden_config/api/serializers.py | 7 ++ nautobot_golden_config/api/urls.py | 2 +- nautobot_golden_config/api/views.py | 70 +++++++++++++---- nautobot_golden_config/tests/test_api.py | 92 +++++++++++++++++------ 5 files changed, 151 insertions(+), 41 deletions(-) diff --git a/docs/user/app_feature_intended.md b/docs/user/app_feature_intended.md index 1ccc3ddc..242d1d83 100644 --- a/docs/user/app_feature_intended.md +++ b/docs/user/app_feature_intended.md @@ -33,6 +33,27 @@ or In these examples, `/services.j2`, `/ntp.j2`, etc. could contain the actual Jinja code which renders the configuration for their corresponding features. Alternately, in more complex environments, these files could themselves contain only include statements in order to create a hierarchy of template files so as to keep each individual file neat and simple. Think of the main, top-level, template as an entrypoint into a hierarchy of templates. A well thought out structure to your templates is necessary to avoid the temptation to place all logic into a small number of templates. Like any code, Jinja2 functions become harder to manage, more buggy, and more fragile as you add complexity, so any thing which you can do to keep them simple will help your automation efforts. +### 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. + +Here’s an example of how to request the rendered configuration for a device: + +```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 +``` + +The returned response will contain the rendered configuration for the specified device. This is the intended workflow for developers: + +- 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. +- Use the API to render the configuration for a device, using the new `GitRepository`. + +Keep in mind that Nautobot only pulls the latest branch updates when you sync the `GitRepository`. If you make changes to the branch after syncing, you'll need to sync the repository again to apply the latest updates. + +Note that this API is only intended to render Jinja2 templates but does not apply any [configuration post-processing](./app_feature_config_postprocessing.md). + ## Adding Jinja2 Filters to the Environment. This app follows [Nautobot](https://docs.nautobot.com/projects/core/en/stable/plugins/development/#including-jinja2-filters) in relying on [django_jinja](https://niwinz.github.io/django-jinja/latest/) for customizing the Jinja2 Environment. Currently, only filters in the `django_jinja` Environment are passed along to the Jinja2 Template Environment used by Nornir to render the config template. diff --git a/nautobot_golden_config/api/serializers.py b/nautobot_golden_config/api/serializers.py index 93aed524..5fdda451 100644 --- a/nautobot_golden_config/api/serializers.py +++ b/nautobot_golden_config/api/serializers.py @@ -124,3 +124,10 @@ class Meta: model = models.ConfigPlan fields = "__all__" read_only_fields = ["device", "plan_type", "feature", "config_set"] + + +class GenerateIntendedConfigSerializer(serializers.Serializer): + """Serializer for GenerateIntendedConfigView.""" + + intended_config = serializers.CharField() + intended_config_lines = serializers.ListField(child=serializers.CharField()) diff --git a/nautobot_golden_config/api/urls.py b/nautobot_golden_config/api/urls.py index 10a9151e..d6b201c8 100644 --- a/nautobot_golden_config/api/urls.py +++ b/nautobot_golden_config/api/urls.py @@ -25,7 +25,7 @@ name="device_detail", ), path( - "generate-intended-config//", + "generate-intended-config/", views.GenerateIntendedConfigView.as_view(), name="generate_intended_config", ), diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py index a08a0697..5d66c4fc 100644 --- a/nautobot_golden_config/api/views.py +++ b/nautobot_golden_config/api/views.py @@ -4,6 +4,8 @@ from pathlib import Path from django.contrib.contenttypes.models import ContentType +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema from jinja2.exceptions import TemplateError, TemplateSyntaxError from nautobot.apps.utils import render_jinja2 from nautobot.core.api.views import ( @@ -15,9 +17,10 @@ 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 rest_framework import mixins, status, viewsets from rest_framework.exceptions import APIException -from rest_framework.generics import RetrieveAPIView +from rest_framework.generics import GenericAPIView from rest_framework.mixins import DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin from rest_framework.permissions import AllowAny, BasePermission, IsAuthenticated from rest_framework.response import Response @@ -177,34 +180,71 @@ class GenerateIntendedConfigException(APIException): default_code = "error" -class GenerateIntendedConfigView(RetrieveAPIView): +class GenerateIntendedConfigView(NautobotAPIVersionMixin, GenericAPIView): """API view for generating the intended config for a Device.""" - queryset = Device.objects.all() - name = "Generate Intended Config" + name = "Generate Intended Config for Device" + permission_classes = [IsAuthenticated] + serializer_class = serializers.GenerateIntendedConfigSerializer + + def _get_object(self, request, model, query_param): + """Get the requested model instance, restricted to requesting user.""" + pk = request.query_params.get(query_param) + if not pk: + raise GenerateIntendedConfigException(f"Parameter {query_param} is required.") + try: + return model.objects.restrict(request.user, "view").get(pk=pk) + except model.DoesNotExist: + raise GenerateIntendedConfigException(f"{model.__name__} with id '{pk}' not found.") - def retrieve(self, request, *args, **kwargs): - """Retrieve intended configuration for a Device.""" - device = self.get_object() + def _get_jinja_template_path(self, settings, device, git_repository): + """Get the Jinja template path for the device in the provided git repository.""" + try: + rendered_path = render_jinja2(template_code=settings.jinja_path_template, context={"obj": device}) + except (TemplateSyntaxError, TemplateError) as exc: + raise GenerateIntendedConfigException(f"Error rendering Jinja path template: {exc}") + filesystem_path = Path(git_repository.filesystem_path) / rendered_path + if not filesystem_path.is_file(): + msg = f"Jinja template {filesystem_path} not found in git repository {git_repository}." + raise GenerateIntendedConfigException(msg) + return filesystem_path + + @extend_schema( + parameters=[ + OpenApiParameter( + name="device_id", + required=True, + 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.""" + 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.jinja_repository: - raise GenerateIntendedConfigException("Golden Config jinja template repository not found.") if not settings.sot_agg_query: raise GenerateIntendedConfigException("Golden Config GraphQL query not found.") try: - ensure_git_repository(settings.jinja_repository) + ensure_git_repository(git_repository) except Exception as exc: - raise GenerateIntendedConfigException(f"Error trying to sync Jinja template repository: {exc}") - filesystem_path = settings.get_jinja_template_path_for_device(device) - if not Path(filesystem_path).is_file(): - raise GenerateIntendedConfigException("Jinja template not found for this device.") + raise GenerateIntendedConfigException(f"Error trying to sync git repository: {exc}") + + filesystem_path = self._get_jinja_template_path(settings, device, git_repository) status_code, context = graph_ql_query(request, device, settings.sot_agg_query.query) if status_code == status.HTTP_200_OK: - template_contents = Path(filesystem_path).read_text() + template_contents = filesystem_path.read_text() try: intended_config = render_jinja2(template_code=template_contents, context=context) except (TemplateSyntaxError, TemplateError) as exc: diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index 8e4a7e91..59f4d36e 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -1,5 +1,6 @@ """Unit tests for nautobot_golden_config.""" +import uuid from copy import deepcopy from unittest.mock import patch @@ -409,11 +410,9 @@ def setUpTestData(cls): } -class GenerateIntendedConfigViewAPITestCase(APIViewTestCases.GetObjectViewTestCase): +class GenerateIntendedConfigViewAPITestCase(APITestCase): """Test API for GenerateIntendedConfigView.""" - model = Device - @classmethod def setUpTestData(cls): create_device_data() @@ -431,27 +430,36 @@ def setUpTestData(cls): 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"), - jinja_repository=GitRepository.objects.get(name="test-jinja-repo-1"), dynamic_group=cls.dynamic_group, ) + cls.git_repository = GitRepository.objects.get(name="test-jinja-repo-1") + + def _setup_mock_path(self, MockPath): + MockPathInstance = MockPath.return_value + MockPathInstance.__str__.return_value = "test.j2" + MockPathInstance.read_text.return_value = r"Jinja test for device {{ name }}." + MockPathInstance.is_file.return_value = True + MockPathInstance.__truediv__.return_value = MockPathInstance # to handle Path('path') / 'file' + return MockPathInstance + @patch("nautobot_golden_config.api.views.ensure_git_repository") @patch("nautobot_golden_config.api.views.Path") def test_generate_intended_config(self, MockPath, mock_ensure_git_repository): """Verify that the intended config is generated as expected.""" self.add_permissions("dcim.view_device") + self.add_permissions("extras.view_gitrepository") - MockPathInstance = MockPath.return_value - MockPathInstance.is_file.return_value = True - MockPathInstance.read_text.return_value = r"Jinja test for device {{ name }}." + self._setup_mock_path(MockPath) response = self.client.get( - reverse("plugins-api:nautobot_golden_config-api:generate_intended_config", kwargs={"pk": self.device.pk}), + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), + data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, **self.header, ) - mock_ensure_git_repository.assert_called_once_with(self.golden_config_setting.jinja_repository) + mock_ensure_git_repository.assert_called_once_with(self.git_repository) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertTrue("intended_config" in response.data) self.assertTrue("intended_config_lines" in response.data) @@ -464,27 +472,59 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos """Verify that errors are handled as expected.""" self.add_permissions("dcim.view_device") - MockPathInstance = MockPath.return_value + self.add_permissions("extras.view_gitrepository") - # test git repo not found + MockPathInstance = self._setup_mock_path(MockPath) + + # 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}, + **self.header, + ) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertTrue("detail" in response.data) + self.assertEqual( + response.data["detail"], + "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 MockPathInstance.is_file.return_value = False response = self.client.get( - reverse("plugins-api:nautobot_golden_config-api:generate_intended_config", kwargs={"pk": self.device.pk}), + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), + data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, **self.header, ) - mock_ensure_git_repository.assert_called_once_with(self.golden_config_setting.jinja_repository) + mock_ensure_git_repository.assert_called_once_with(self.git_repository) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertEqual(response.data["detail"], "Jinja template not found for this device.") + self.assertEqual( + response.data["detail"], + f"Jinja template test.j2 not found in git repository {self.git_repository}.", + ) # test invalid jinja template MockPathInstance.is_file.return_value = True MockPathInstance.read_text.return_value = r"Jinja test for device {{ name }." response = self.client.get( - reverse("plugins-api:nautobot_golden_config-api:generate_intended_config", kwargs={"pk": self.device.pk}), + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), + data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, **self.header, ) @@ -496,20 +536,22 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos mock_ensure_git_repository.side_effect = Exception("Test exception") response = self.client.get( - reverse("plugins-api:nautobot_golden_config-api:generate_intended_config", kwargs={"pk": self.device.pk}), + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), + data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, **self.header, ) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertIn("Error trying to sync Jinja template repository:", response.data["detail"]) + self.assertEqual("Error trying to sync git repository: Test exception", response.data["detail"]) # test no sot_agg_query on GoldenConfigSetting self.golden_config_setting.sot_agg_query = None self.golden_config_setting.save() response = self.client.get( - reverse("plugins-api:nautobot_golden_config-api:generate_intended_config", kwargs={"pk": self.device.pk}), + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), + data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, **self.header, ) @@ -517,23 +559,23 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos self.assertTrue("detail" in response.data) self.assertIn("Golden Config GraphQL query not found.", response.data["detail"]) - # test no jinja_repository on GoldenConfigSetting - self.golden_config_setting.jinja_repository = 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", kwargs={"pk": self.device.pk}), + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), + data={"device_id": self.device.pk, "git_repository_id": invalid_uuid}, **self.header, ) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertIn("Golden Config jinja template repository not found.", response.data["detail"]) + self.assertEqual(f"GitRepository with id '{invalid_uuid}' not found.", 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", kwargs={"pk": self.device.pk}), + reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), + data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk}, **self.header, ) From 37bf820c16eb649e62d0aaf800646adc2b81e436 Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Thu, 24 Oct 2024 14:29:29 -0700 Subject: [PATCH 07/15] changelog, pylint --- changes/824.added | 1 + changes/824.housekeeping | 1 + nautobot_golden_config/api/serializers.py | 2 +- nautobot_golden_config/api/views.py | 10 ++++----- nautobot_golden_config/models.py | 1 + nautobot_golden_config/tests/test_api.py | 26 +++++++++++------------ 6 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 changes/824.added create mode 100644 changes/824.housekeeping diff --git a/changes/824.added b/changes/824.added new file mode 100644 index 00000000..7a025d98 --- /dev/null +++ b/changes/824.added @@ -0,0 +1 @@ +Added a REST API endpoint for Jinja template developers to render intended configurations from templates in an arbitrary git repository. diff --git a/changes/824.housekeeping b/changes/824.housekeeping new file mode 100644 index 00000000..bb14b698 --- /dev/null +++ b/changes/824.housekeeping @@ -0,0 +1 @@ +Updated multiple tests to use the faster `setUpTestData` instead of `setUp`. Fixed incorrect base class on `ConfigPlanTest`. diff --git a/nautobot_golden_config/api/serializers.py b/nautobot_golden_config/api/serializers.py index 5fdda451..64ba2120 100644 --- a/nautobot_golden_config/api/serializers.py +++ b/nautobot_golden_config/api/serializers.py @@ -126,7 +126,7 @@ class Meta: read_only_fields = ["device", "plan_type", "feature", "config_set"] -class GenerateIntendedConfigSerializer(serializers.Serializer): +class GenerateIntendedConfigSerializer(serializers.Serializer): # pylint: disable=abstract-method """Serializer for GenerateIntendedConfigView.""" intended_config = serializers.CharField() diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py index 5d66c4fc..568109d1 100644 --- a/nautobot_golden_config/api/views.py +++ b/nautobot_golden_config/api/views.py @@ -194,15 +194,15 @@ def _get_object(self, request, model, query_param): raise GenerateIntendedConfigException(f"Parameter {query_param} is required.") try: return model.objects.restrict(request.user, "view").get(pk=pk) - except model.DoesNotExist: - raise GenerateIntendedConfigException(f"{model.__name__} with id '{pk}' not found.") + except model.DoesNotExist as exc: + raise GenerateIntendedConfigException(f"{model.__name__} with id '{pk}' not found.") from exc def _get_jinja_template_path(self, settings, device, git_repository): """Get the Jinja template path for the device in the provided git repository.""" try: rendered_path = render_jinja2(template_code=settings.jinja_path_template, context={"obj": device}) except (TemplateSyntaxError, TemplateError) as exc: - raise GenerateIntendedConfigException(f"Error rendering Jinja path template: {exc}") + raise GenerateIntendedConfigException("Error rendering Jinja path template") from exc filesystem_path = Path(git_repository.filesystem_path) / rendered_path if not filesystem_path.is_file(): msg = f"Jinja template {filesystem_path} not found in git repository {git_repository}." @@ -238,7 +238,7 @@ def get(self, request, *args, **kwargs): try: ensure_git_repository(git_repository) except Exception as exc: - raise GenerateIntendedConfigException(f"Error trying to sync git repository: {exc}") + raise GenerateIntendedConfigException("Error trying to sync git repository") from exc filesystem_path = self._get_jinja_template_path(settings, device, git_repository) @@ -248,7 +248,7 @@ def get(self, request, *args, **kwargs): try: intended_config = render_jinja2(template_code=template_contents, context=context) except (TemplateSyntaxError, TemplateError) as exc: - raise GenerateIntendedConfigException(f"Error rendering Jinja template: {exc}") + raise GenerateIntendedConfigException("Error rendering Jinja template") from exc return Response( data={ "intended_config": intended_config, diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index 50fd9ef3..72b323db 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -633,6 +633,7 @@ def get_jinja_template_path_for_device(self, device): if self.jinja_repository is not None: rendered_path = render_jinja2(template_code=self.jinja_path_template, context={"obj": device}) return f"{self.jinja_repository.filesystem_path}{os.path.sep}{rendered_path}" + return None @extras_features( diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index 59f4d36e..7c79b9a2 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -435,17 +435,17 @@ def setUpTestData(cls): cls.git_repository = GitRepository.objects.get(name="test-jinja-repo-1") - def _setup_mock_path(self, MockPath): - MockPathInstance = MockPath.return_value - MockPathInstance.__str__.return_value = "test.j2" - MockPathInstance.read_text.return_value = r"Jinja test for device {{ name }}." - MockPathInstance.is_file.return_value = True - MockPathInstance.__truediv__.return_value = MockPathInstance # to handle Path('path') / 'file' - return MockPathInstance + def _setup_mock_path(self, MockPath): # pylint: disable=invalid-name + mock_path_instance = MockPath.return_value + mock_path_instance.__str__.return_value = "test.j2" + mock_path_instance.read_text.return_value = r"Jinja test for device {{ name }}." + mock_path_instance.is_file.return_value = True + mock_path_instance.__truediv__.return_value = mock_path_instance # to handle Path('path') / 'file' + return mock_path_instance @patch("nautobot_golden_config.api.views.ensure_git_repository") @patch("nautobot_golden_config.api.views.Path") - def test_generate_intended_config(self, MockPath, mock_ensure_git_repository): + def test_generate_intended_config(self, MockPath, mock_ensure_git_repository): # pylint: disable=invalid-name """Verify that the intended config is generated as expected.""" self.add_permissions("dcim.view_device") @@ -468,13 +468,13 @@ def test_generate_intended_config(self, MockPath, mock_ensure_git_repository): @patch("nautobot_golden_config.api.views.ensure_git_repository") @patch("nautobot_golden_config.api.views.Path") - def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repository): + def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repository): # pylint: disable=invalid-name """Verify that errors are handled as expected.""" self.add_permissions("dcim.view_device") self.add_permissions("extras.view_gitrepository") - MockPathInstance = self._setup_mock_path(MockPath) + mock_path_instance = self._setup_mock_path(MockPath) # test missing query parameters response = self.client.get( @@ -502,7 +502,7 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos ) # test git repo not present on filesystem - MockPathInstance.is_file.return_value = False + mock_path_instance.is_file.return_value = False response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), @@ -519,8 +519,8 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos ) # test invalid jinja template - MockPathInstance.is_file.return_value = True - MockPathInstance.read_text.return_value = r"Jinja test for device {{ name }." + mock_path_instance.is_file.return_value = True + mock_path_instance.read_text.return_value = r"Jinja test for device {{ name }." response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), From e172053eb923abdda728b7ea29cb5bec6a289791 Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Thu, 24 Oct 2024 15:23:57 -0700 Subject: [PATCH 08/15] fix tests --- nautobot_golden_config/tests/test_api.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index 7c79b9a2..ce52b5cb 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -510,7 +510,6 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos **self.header, ) - mock_ensure_git_repository.assert_called_once_with(self.git_repository) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) self.assertEqual( @@ -530,7 +529,7 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertIn("Error rendering Jinja template:", response.data["detail"]) + self.assertEqual("Error rendering Jinja template", response.data["detail"]) # test ensure_git_repository failure mock_ensure_git_repository.side_effect = Exception("Test exception") @@ -543,7 +542,7 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertEqual("Error trying to sync git repository: Test exception", response.data["detail"]) + 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 @@ -557,7 +556,7 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertIn("Golden Config GraphQL query not found.", response.data["detail"]) + self.assertEqual("Golden Config GraphQL query not found.", response.data["detail"]) # test git_repository instance not found invalid_uuid = uuid.uuid4() @@ -581,4 +580,4 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertIn("No Golden Config settings found for this device.", response.data["detail"]) + self.assertEqual("No Golden Config settings found for this device.", response.data["detail"]) From 096b9f0f728048af8730862085f25c7df88965e6 Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Thu, 24 Oct 2024 17:11:50 -0700 Subject: [PATCH 09/15] fix incompatibility with Nautobot v2.2 and below --- nautobot_golden_config/api/views.py | 12 ++++++------ nautobot_golden_config/tests/test_api.py | 14 ++++++++------ nautobot_golden_config/tests/test_models.py | 4 ++++ 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py index 568109d1..8978bedf 100644 --- a/nautobot_golden_config/api/views.py +++ b/nautobot_golden_config/api/views.py @@ -191,11 +191,11 @@ def _get_object(self, request, model, query_param): """Get the requested model instance, restricted to requesting user.""" pk = request.query_params.get(query_param) if not pk: - raise GenerateIntendedConfigException(f"Parameter {query_param} is required.") + raise GenerateIntendedConfigException(f"Parameter {query_param} is required") try: return model.objects.restrict(request.user, "view").get(pk=pk) except model.DoesNotExist as exc: - raise GenerateIntendedConfigException(f"{model.__name__} with id '{pk}' not found.") from exc + raise GenerateIntendedConfigException(f"{model.__name__} with id '{pk}' not found") from exc def _get_jinja_template_path(self, settings, device, git_repository): """Get the Jinja template path for the device in the provided git repository.""" @@ -205,7 +205,7 @@ def _get_jinja_template_path(self, settings, device, git_repository): raise GenerateIntendedConfigException("Error rendering Jinja path template") from exc filesystem_path = Path(git_repository.filesystem_path) / rendered_path if not filesystem_path.is_file(): - msg = f"Jinja template {filesystem_path} not found in git repository {git_repository}." + msg = f"Jinja template {filesystem_path} not found in git repository {git_repository}" raise GenerateIntendedConfigException(msg) return filesystem_path @@ -231,9 +231,9 @@ def get(self, request, *args, **kwargs): 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.") + raise GenerateIntendedConfigException("No Golden Config settings found for this device") if not settings.sot_agg_query: - raise GenerateIntendedConfigException("Golden Config GraphQL query not found.") + raise GenerateIntendedConfigException("Golden Config settings sot_agg_query not set") try: ensure_git_repository(git_repository) @@ -257,4 +257,4 @@ def get(self, request, *args, **kwargs): status=status.HTTP_200_OK, ) - raise GenerateIntendedConfigException("Unable to generate the intended config for this device.") + raise GenerateIntendedConfigException("Unable to generate the intended config for this device") diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index ce52b5cb..d68cbc78 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -415,6 +415,8 @@ class GenerateIntendedConfigViewAPITestCase(APITestCase): @classmethod def setUpTestData(cls): + # Delete the automatically created GoldenConfigSetting object + GoldenConfigSetting.objects.all().delete() create_device_data() create_git_repos() create_saved_queries() @@ -486,7 +488,7 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos self.assertTrue("detail" in response.data) self.assertEqual( response.data["detail"], - "Parameter device_id is required.", + "Parameter device_id is required", ) response = self.client.get( @@ -498,7 +500,7 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos self.assertTrue("detail" in response.data) self.assertEqual( response.data["detail"], - "Parameter git_repository_id is required.", + "Parameter git_repository_id is required", ) # test git repo not present on filesystem @@ -514,7 +516,7 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos self.assertTrue("detail" in response.data) self.assertEqual( response.data["detail"], - f"Jinja template test.j2 not found in git repository {self.git_repository}.", + f"Jinja template test.j2 not found in git repository {self.git_repository}", ) # test invalid jinja template @@ -556,7 +558,7 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertEqual("Golden Config GraphQL query not found.", response.data["detail"]) + self.assertEqual("Golden Config settings sot_agg_query not set", response.data["detail"]) # test git_repository instance not found invalid_uuid = uuid.uuid4() @@ -568,7 +570,7 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos 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(f"GitRepository with id '{invalid_uuid}' not found", response.data["detail"]) # test no GoldenConfigSetting found for device GoldenConfigSetting.objects.all().delete() @@ -580,4 +582,4 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertTrue("detail" in response.data) - self.assertEqual("No Golden Config settings found for this device.", response.data["detail"]) + self.assertEqual("No Golden Config settings found for this device", response.data["detail"]) diff --git a/nautobot_golden_config/tests/test_models.py b/nautobot_golden_config/tests/test_models.py index c6ee4f44..15ac2af9 100644 --- a/nautobot_golden_config/tests/test_models.py +++ b/nautobot_golden_config/tests/test_models.py @@ -266,6 +266,8 @@ def test_get_for_device(self): ) self.dynamic_group.update_cached_members() + if hasattr(device, "_dynamic_groups"): # clear Device.dynamic_groups cache in nautobot <2.3 + delattr(device, "_dynamic_groups") self.assertEqual(GoldenConfigSetting.objects.get_for_device(device), self.global_settings) other_settings.weight = 2000 @@ -279,6 +281,8 @@ def test_get_for_device(self): other_dynamic_group.save() self.dynamic_group.update_cached_members() other_dynamic_group.update_cached_members() + if hasattr(device, "_dynamic_groups"): # clear Device.dynamic_groups cache in nautobot <2.3 + delattr(device, "_dynamic_groups") self.assertIsNone(GoldenConfigSetting.objects.get_for_device(device)) def test_get_jinja_template_path_for_device(self): From 9644d7b5c07b428d0a08407833f69a4b788b4e8d Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Fri, 25 Oct 2024 08:27:32 -0700 Subject: [PATCH 10/15] fix invalid html in tables.py --- nautobot_golden_config/tables.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nautobot_golden_config/tables.py b/nautobot_golden_config/tables.py index 5b33acfc..9c1386ef 100644 --- a/nautobot_golden_config/tables.py +++ b/nautobot_golden_config/tables.py @@ -485,12 +485,12 @@ class ConfigPlanTable(StatusTableMixin, BaseTable): pk = ToggleColumn() device = LinkColumn("plugins:nautobot_golden_config:configplan", args=[A("pk")]) plan_result = TemplateColumn( - template_code=""" """ + template_code="""""" ) deploy_result = TemplateColumn( template_code=""" {% if record.deploy_result %} - + {% else %} — {% endif %} From e217856826e9f18b964ed22701b25f649b852f19 Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:27:46 -0700 Subject: [PATCH 11/15] refactor to use nornir --- nautobot_golden_config/api/views.py | 62 ++++++++++++++++++++++-- nautobot_golden_config/tests/test_api.py | 44 +++++++++++++++-- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py index 8978bedf..a77e44ce 100644 --- a/nautobot_golden_config/api/views.py +++ b/nautobot_golden_config/api/views.py @@ -1,9 +1,12 @@ """View for Golden Config APIs.""" +import datetime import json +import logging from pathlib import Path from django.contrib.contenttypes.models import ContentType +from django.utils.timezone import make_aware from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema from jinja2.exceptions import TemplateError, TemplateSyntaxError @@ -18,6 +21,9 @@ 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 from rest_framework import mixins, status, viewsets from rest_framework.exceptions import APIException from rest_framework.generics import GenericAPIView @@ -31,7 +37,7 @@ from nautobot_golden_config import filters, models from nautobot_golden_config.api import serializers from nautobot_golden_config.utilities.graphql import graph_ql_query -from nautobot_golden_config.utilities.helper import get_device_to_settings_map +from nautobot_golden_config.utilities.helper import dispatch_params, get_device_to_settings_map, get_django_env class GoldenConfigRootView(APIRootView): @@ -180,6 +186,16 @@ class GenerateIntendedConfigException(APIException): default_code = "error" +def _nornir_task_inject_graphql_data(task, graphql_data, **kwargs): + """Inject the GraphQL data into the Nornir task host data and then run dispatcher. + + This is a small stub of the logic in nautobot_golden_config.nornir_plays.config_intended.run_template. + """ + task.host.data.update(graphql_data) + generated_config = task.run(task=dispatcher, name="GENERATE CONFIG", **kwargs) + return generated_config + + class GenerateIntendedConfigView(NautobotAPIVersionMixin, GenericAPIView): """API view for generating the intended config for a Device.""" @@ -244,10 +260,14 @@ def get(self, request, *args, **kwargs): status_code, context = graph_ql_query(request, device, settings.sot_agg_query.query) if status_code == status.HTTP_200_OK: - template_contents = filesystem_path.read_text() try: - intended_config = render_jinja2(template_code=template_contents, context=context) - except (TemplateSyntaxError, TemplateError) as exc: + intended_config = self._render_config_nornir_serial( + device=device, + jinja_template=filesystem_path.name, + filesystem_path=filesystem_path.parent, + graphql_data=context, + ) + except Exception as exc: raise GenerateIntendedConfigException("Error rendering Jinja template") from exc return Response( data={ @@ -258,3 +278,37 @@ def get(self, request, *args, **kwargs): ) raise GenerateIntendedConfigException("Unable to generate the intended config for this device") + + def _render_config_nornir_serial(self, device, jinja_template, filesystem_path, graphql_data): + jinja_env = get_django_env() + with InitNornir( + runner={"plugin": "serial"}, + logging={"enabled": False}, + inventory={ + "plugin": "nautobot-inventory", + "options": { + "credentials_class": NORNIR_SETTINGS.get("credentials"), + "params": NORNIR_SETTINGS.get("inventory_params"), + "queryset": Device.objects.filter(pk=device.pk), + "defaults": {"now": make_aware(datetime.datetime.now())}, + }, + }, + ) as nornir_obj: + results = nornir_obj.run( + task=_nornir_task_inject_graphql_data, + name="REST API GENERATE CONFIG", + graphql_data=graphql_data, + obj=device, # Used by the nornir tasks for logging to the logger below + logger=logging.getLogger( + dispatcher.__module__ + ), # The nornir tasks are built for logging to a JobResult, pass a standard logger here + jinja_template=jinja_template, + jinja_root_path=filesystem_path, + output_file_location="/dev/null", # The nornir task outputs the templated config to a file, but this API doesn't need it + jinja_filters=jinja_env.filters, + jinja_env=jinja_env, + **dispatch_params( + "generate_config", device.platform.network_driver, logging.getLogger(dispatch_params.__module__) + ), + ) + return results[device.name][1][1][0].result["config"] diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index d68cbc78..6169cbec 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -9,6 +9,7 @@ from django.urls import reverse from nautobot.core.testing import APITestCase, APIViewTestCases from nautobot.dcim.models import Device, Platform +from nautobot.extras.management import populate_role_choices, populate_status_choices from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, Status from rest_framework import status @@ -415,6 +416,8 @@ class GenerateIntendedConfigViewAPITestCase(APITestCase): @classmethod def setUpTestData(cls): + populate_role_choices() + populate_status_choices() # Delete the automatically created GoldenConfigSetting object GoldenConfigSetting.objects.all().delete() create_device_data() @@ -427,6 +430,9 @@ def setUpTestData(cls): ) cls.device = Device.objects.get(name="Device 1") + platform = cls.device.platform + platform.network_driver = "arista_eos" + platform.save() cls.golden_config_setting = GoldenConfigSetting.objects.create( name="GoldenConfigSetting test api generate intended config", @@ -440,14 +446,14 @@ def setUpTestData(cls): def _setup_mock_path(self, MockPath): # pylint: disable=invalid-name mock_path_instance = MockPath.return_value mock_path_instance.__str__.return_value = "test.j2" - mock_path_instance.read_text.return_value = r"Jinja test for device {{ name }}." mock_path_instance.is_file.return_value = True mock_path_instance.__truediv__.return_value = mock_path_instance # to handle Path('path') / 'file' return mock_path_instance @patch("nautobot_golden_config.api.views.ensure_git_repository") @patch("nautobot_golden_config.api.views.Path") - def test_generate_intended_config(self, MockPath, mock_ensure_git_repository): # pylint: disable=invalid-name + @patch("nautobot_golden_config.api.views.dispatcher") + def test_generate_intended_config(self, mock_dispatcher, MockPath, mock_ensure_git_repository): # pylint: disable=invalid-name """Verify that the intended config is generated as expected.""" self.add_permissions("dcim.view_device") @@ -455,6 +461,20 @@ def test_generate_intended_config(self, MockPath, mock_ensure_git_repository): self._setup_mock_path(MockPath) + # Replicate nornir nested task structure + def _mock_dispatcher(task, *args, **kwargs): + def _template_file(task, *args, **kwargs): + return None + + def _generate_config(task, *args, **kwargs): + task.run(task=_template_file, name="template_file") + return {"config": f"Jinja test for device {self.device.name}."} + + task.run(task=_generate_config, name="generate_config") + return "" + + mock_dispatcher.side_effect = _mock_dispatcher + 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}, @@ -470,7 +490,8 @@ def test_generate_intended_config(self, MockPath, mock_ensure_git_repository): @patch("nautobot_golden_config.api.views.ensure_git_repository") @patch("nautobot_golden_config.api.views.Path") - def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repository): # pylint: disable=invalid-name + @patch("nautobot_golden_config.api.views.dispatcher") + def test_generate_intended_config_failures(self, mock_dispatcher, MockPath, mock_ensure_git_repository): # pylint: disable=invalid-name """Verify that errors are handled as expected.""" self.add_permissions("dcim.view_device") @@ -519,9 +540,22 @@ def test_generate_intended_config_failures(self, MockPath, mock_ensure_git_repos f"Jinja template test.j2 not found in git repository {self.git_repository}", ) - # test invalid jinja template + # test exception raised in nornir task + + # Replicate nornir nested task structure + def _mock_dispatcher(task, *args, **kwargs): + def _template_file(task, *args, **kwargs): + raise Exception("Test exception") + + def _generate_config(task, *args, **kwargs): + task.run(task=_template_file, name="template_file") + return {"config": f"Jinja test for device {self.device.name}."} + + task.run(task=_generate_config, name="generate_config") + return "" + + mock_dispatcher.side_effect = _mock_dispatcher mock_path_instance.is_file.return_value = True - mock_path_instance.read_text.return_value = r"Jinja test for device {{ name }." response = self.client.get( reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"), From b99ae090e1a198302d72e7c5a6df9178099db80c Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:35:59 -0700 Subject: [PATCH 12/15] docs, clean up --- nautobot_golden_config/api/views.py | 12 ++++++++---- nautobot_golden_config/tests/test_api.py | 3 --- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py index a77e44ce..17358c72 100644 --- a/nautobot_golden_config/api/views.py +++ b/nautobot_golden_config/api/views.py @@ -187,7 +187,7 @@ class GenerateIntendedConfigException(APIException): def _nornir_task_inject_graphql_data(task, graphql_data, **kwargs): - """Inject the GraphQL data into the Nornir task host data and then run dispatcher. + """Inject the GraphQL data into the Nornir task host data and then run nornir_nautobot.plugins.tasks.dispatcher.dispatcher subtask. This is a small stub of the logic in nautobot_golden_config.nornir_plays.config_intended.run_template. """ @@ -264,7 +264,7 @@ def get(self, request, *args, **kwargs): intended_config = self._render_config_nornir_serial( device=device, jinja_template=filesystem_path.name, - filesystem_path=filesystem_path.parent, + jinja_root_path=filesystem_path.parent, graphql_data=context, ) except Exception as exc: @@ -279,7 +279,11 @@ def get(self, request, *args, **kwargs): raise GenerateIntendedConfigException("Unable to generate the intended config for this device") - def _render_config_nornir_serial(self, device, jinja_template, filesystem_path, graphql_data): + def _render_config_nornir_serial(self, device, jinja_template, jinja_root_path, graphql_data): + """Render the Jinja template for the device using Nornir serial runner. + + This is a small stub of the logic in nornir_plays.config_intended.config_intended. + """ jinja_env = get_django_env() with InitNornir( runner={"plugin": "serial"}, @@ -303,7 +307,7 @@ def _render_config_nornir_serial(self, device, jinja_template, filesystem_path, dispatcher.__module__ ), # The nornir tasks are built for logging to a JobResult, pass a standard logger here jinja_template=jinja_template, - jinja_root_path=filesystem_path, + jinja_root_path=jinja_root_path, output_file_location="/dev/null", # The nornir task outputs the templated config to a file, but this API doesn't need it jinja_filters=jinja_env.filters, jinja_env=jinja_env, diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index 6169cbec..203d025d 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -9,7 +9,6 @@ from django.urls import reverse from nautobot.core.testing import APITestCase, APIViewTestCases from nautobot.dcim.models import Device, Platform -from nautobot.extras.management import populate_role_choices, populate_status_choices from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, Status from rest_framework import status @@ -416,8 +415,6 @@ class GenerateIntendedConfigViewAPITestCase(APITestCase): @classmethod def setUpTestData(cls): - populate_role_choices() - populate_status_choices() # Delete the automatically created GoldenConfigSetting object GoldenConfigSetting.objects.all().delete() create_device_data() From a09fcfc00f9bef9b91de18239bba45589c18d5e8 Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:46:04 -0700 Subject: [PATCH 13/15] pylint --- nautobot_golden_config/tests/test_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index 203d025d..88ed0d8f 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -460,7 +460,7 @@ def test_generate_intended_config(self, mock_dispatcher, MockPath, mock_ensure_g # Replicate nornir nested task structure def _mock_dispatcher(task, *args, **kwargs): - def _template_file(task, *args, **kwargs): + def _template_file(*args, **kwargs): return None def _generate_config(task, *args, **kwargs): @@ -541,8 +541,8 @@ def test_generate_intended_config_failures(self, mock_dispatcher, MockPath, mock # Replicate nornir nested task structure def _mock_dispatcher(task, *args, **kwargs): - def _template_file(task, *args, **kwargs): - raise Exception("Test exception") + def _template_file(*args, **kwargs): + raise Exception("Test exception") # pylint: disable=broad-exception-raised def _generate_config(task, *args, **kwargs): task.run(task=_template_file, name="template_file") From 72f106eebe6d83967453a1c35073132b54bcdf71 Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Mon, 4 Nov 2024 13:19:30 -0800 Subject: [PATCH 14/15] update docs --- docs/user/app_feature_intended.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/user/app_feature_intended.md b/docs/user/app_feature_intended.md index 242d1d83..d0e00d35 100644 --- a/docs/user/app_feature_intended.md +++ b/docs/user/app_feature_intended.md @@ -48,11 +48,13 @@ The returned response will contain the rendered configuration for the specified - 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`. -Keep in mind that Nautobot only pulls the latest branch updates when you sync the `GitRepository`. If you make changes to the branch after syncing, you'll need to sync the repository again to apply the latest updates. +Calling this API endpoint automatically performs a `git pull`, retrieving the latest commit from the branch before rendering the template. -Note that this API is only intended to render Jinja2 templates but does not apply any [configuration post-processing](./app_feature_config_postprocessing.md). +Note that this API is only intended to render Jinja2 templates and does not apply any [configuration post-processing](./app_feature_config_postprocessing.md). ## Adding Jinja2 Filters to the Environment. From d8b7b1e034c58697924417e3e55217b3fb59f455 Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Mon, 4 Nov 2024 13:44:42 -0800 Subject: [PATCH 15/15] remove msword curly braces --- docs/user/app_feature_intended.md | 2 +- docs/user/app_overview.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/app_feature_intended.md b/docs/user/app_feature_intended.md index d0e00d35..81c78a34 100644 --- a/docs/user/app_feature_intended.md +++ b/docs/user/app_feature_intended.md @@ -37,7 +37,7 @@ In these examples, `/services.j2`, `/ntp.j2`, etc. could contain the actual Jinj 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. -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: ```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 diff --git a/docs/user/app_overview.md b/docs/user/app_overview.md index a2192628..ec32aefb 100644 --- a/docs/user/app_overview.md +++ b/docs/user/app_overview.md @@ -7,7 +7,7 @@ This document provides an overview of the App including critical information and ## Description -When engineers are starting their network automation journey, everybody asks where and how they should start. Their immediate thought is coming up with methods of automating changes within their environments. However, doing so can be scary for those who are risk averse about automation making changes. The question then comes about how automation can be used to help solve some of the big problems facing network teams today. One of those problems that we’ve repeatedly heard from our customers and fellow network engineers is around configuration drift. This issue typically occurs for multiple reasons: +When engineers are starting their network automation journey, everybody asks where and how they should start. Their immediate thought is coming up with methods of automating changes within their environments. However, doing so can be scary for those who are risk averse about automation making changes. The question then comes about how automation can be used to help solve some of the big problems facing network teams today. One of those problems that we've repeatedly heard from our customers and fellow network engineers is around configuration drift. This issue typically occurs for multiple reasons: - Lack of standardization for device configurations - Multiple individuals independently making changes