Skip to content

Commit

Permalink
Added a web ui to render intended configurations (#827)
Browse files Browse the repository at this point in the history
Co-authored-by: Ken Celenza <[email protected]>
Co-authored-by: Jeff Kala <[email protected]>
  • Loading branch information
3 people authored Nov 13, 2024
1 parent 69e3b28 commit e14792f
Show file tree
Hide file tree
Showing 12 changed files with 181 additions and 56 deletions.
1 change: 1 addition & 0 deletions changes/827.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a web ui for Jinja template developers to render intended configurations.
Binary file added docs/images/generate-intended-config-ui-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/generate-intended-config-ui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 9 additions & 11 deletions docs/user/app_feature_intended.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,22 @@ In these examples, `/services.j2`, `/ntp.j2`, etc. could contain the actual Jinj

### Developing Intended Configuration Templates

To help developers create the Jinja2 templates for generating the intended configuration, the app provides a REST API at `/api/plugins/golden-config/generate-intended-config/`. This API accepts two query parameters: `device_id` and `git_repository_id`. It returns the rendered configuration for the specified device using the templates from the given Git repository. This feature allows developers to test their configuration templates using a custom `GitRepository` without running a full intended configuration job.
To help developers create the Jinja2 templates for generating a device's intended configuration, the app provides a REST API at `/api/plugins/golden-config/generate-intended-config/` and a simple web UI at `/plugins/golden-config/generate-intended-config/`. The REST API accepts a query parameter for `device_id` and returns the rendered configuration for the specified device using the templates from the device's golden config `jinja_repository` Git repository. This feature allows developers to test their configuration templates without running a full "intended configuration" job.

Here's an example of how to request the rendered configuration for a device:
Here's an example of how to request the rendered configuration for a device using the REST API:

```no-highlight
GET /api/plugins/golden-config/generate-intended-config/?device_id=231b8765-054d-4abe-bdbf-cd60e049cd8d&git_repository_id=82c051e0-d0a9-4008-948a-936a409c654a
curl -s -X GET \
-H "Accept: application/json" \
http://nautobot/api/plugins/golden-config/generate-intended-config/?device_id=231b8765-054d-4abe-bdbf-cd60e049cd8d
```

The returned response will contain the rendered configuration for the specified device. This is the intended workflow for developers:
The returned response will contain the rendered configuration for the specified device. The web UI provides a simple form to input the device and displays the rendered configuration when submitted.

- Create a new branch in the intended configuration repository.
- Modify the Jinja2 templates in that new branch.
- Add a new `GitRepository` in Nautobot that points to the new branch and sync the repository.
- NOTE: Do not select the "jinja templates" option under the "Provides" field when creating the `GitRepository`. Nautobot does not allow multiple `GitRepository` instances with an identical URL and "Provided Content". This API ignores the "Provided Content" field for this reason.
- Don't forget to associate credentials required to access the repository using the "Secrets Group" field.
- Use the API to render the configuration for a device, using the new `GitRepository`.
![Intended Configuration Web UI](../images/generate-intended-config-ui.png#only-light)
![Intended Configuration Web UI](../images/generate-intended-config-ui-dark.png#only-dark)

Calling this API endpoint automatically performs a `git pull`, retrieving the latest commit from the branch before rendering the template.
Calling this API endpoint automatically performs a `git pull`, retrieving the latest commit from the Jinja2 templates Git repository before rendering the template.

Note that this API is only intended to render Jinja2 templates and does not apply any [configuration post-processing](./app_feature_config_postprocessing.md).

Expand Down
4 changes: 2 additions & 2 deletions nautobot_golden_config/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,5 @@ class Meta:
class GenerateIntendedConfigSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializer for GenerateIntendedConfigView."""

intended_config = serializers.CharField()
intended_config_lines = serializers.ListField(child=serializers.CharField())
intended_config = serializers.CharField(read_only=True)
intended_config_lines = serializers.ListField(read_only=True, child=serializers.CharField())
25 changes: 14 additions & 11 deletions nautobot_golden_config/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
from nautobot.dcim.models import Device
from nautobot.extras.api.views import NautobotModelViewSet, NotesViewSetMixin
from nautobot.extras.datasources.git import ensure_git_repository
from nautobot.extras.models import GitRepository
from nautobot_plugin_nornir.constants import NORNIR_SETTINGS
from nornir import InitNornir
from nornir_nautobot.plugins.tasks.dispatcher import dispatcher
Expand Down Expand Up @@ -233,25 +232,21 @@ def _get_jinja_template_path(self, settings, device, git_repository):
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
),
OpenApiParameter(
name="git_repository_id",
required=True,
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
),
]
)
def get(self, request, *args, **kwargs):
"""Generate intended configuration for a Device with an arbitrary GitRepository."""
"""Generate intended configuration for a Device."""
device = self._get_object(request, Device, "device_id")
git_repository = self._get_object(request, GitRepository, "git_repository_id")
settings = models.GoldenConfigSetting.objects.get_for_device(device)
if not settings:
raise GenerateIntendedConfigException("No Golden Config settings found for this device")
if not settings.sot_agg_query:
raise GenerateIntendedConfigException("Golden Config settings sot_agg_query not set")
if not settings.jinja_repository:
raise GenerateIntendedConfigException("Golden Config settings jinja_repository not set")

try:
git_repository = settings.jinja_repository
ensure_git_repository(git_repository)
except Exception as exc:
raise GenerateIntendedConfigException("Error trying to sync git repository") from exc
Expand All @@ -268,7 +263,7 @@ def get(self, request, *args, **kwargs):
graphql_data=context,
)
except Exception as exc:
raise GenerateIntendedConfigException("Error rendering Jinja template") from exc
raise GenerateIntendedConfigException(f"Error rendering Jinja template: {exc}") from exc
return Response(
data={
"intended_config": intended_config,
Expand Down Expand Up @@ -315,4 +310,12 @@ def _render_config_nornir_serial(self, device, jinja_template, jinja_root_path,
"generate_config", device.platform.network_driver, logging.getLogger(dispatch_params.__module__)
),
)
return results[device.name][1][1][0].result["config"]
if results[device.name].failed:
if results[device.name].exception: # pylint: disable=no-else-raise
raise results[device.name].exception
else:
raise GenerateIntendedConfigException(
f"Error generating intended config for {device.name}: {results[device.name].result}"
)
else:
return results[device.name][1][1][0].result["config"]
10 changes: 10 additions & 0 deletions nautobot_golden_config/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,3 +598,13 @@ class Meta:
"change_control_url",
"tags",
]


class GenerateIntendedConfigForm(django_forms.Form):
"""Form for generating intended configuration."""

device = forms.DynamicModelChoiceField(
queryset=Device.objects.all(),
required=True,
label="Device",
)
11 changes: 11 additions & 0 deletions nautobot_golden_config/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,17 @@
groups=(
NavMenuGroup(name="Manage", weight=100, items=tuple(items_operate)),
NavMenuGroup(name="Setup", weight=100, items=tuple(items_setup)),
NavMenuGroup(
name="Tools",
weight=300,
items=(
NavMenuItem(
link="plugins:nautobot_golden_config:generate_intended_config",
name="Generate Intended Config",
permissions=["dcim.view_device", "extras.view_gitrepository"],
),
),
),
),
),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
{% extends "base.html" %}
{% load form_helpers %}
{% load helpers %}
{% load static %}

{% block extra_styles %}
<style type="text/css">
.button-container {
margin-bottom: 24px;
}
</style>
{% endblock extra_styles %}

{% block content %}
<form class="form form-horizontal" onsubmit="handleFormSubmit(event)">
<div class="row">
<div class="col-lg-6 col-md-6">
<div class="panel panel-default">
<div class="panel-heading"><strong>{% block title %}Generate Intended Configuration{% endblock title %}</strong></div>
<div class="panel-body">
<p>
This tool is <strong>intended for template developers</strong>. Production configuration generation should be initiated from the
<a href="{% url 'plugins:nautobot_golden_config:goldenconfig_list' %}">Config Overview</a> page.
</p>
<p>
This will render the configuration for the selected device using Jinja templates from the golden config <code>jinja_repository</code>
Git repository for that device.
This feature allows developers to test their configuration templates without running a full "intended configuration" job. See the
<a href="{% static 'nautobot_golden_config/docs/user/app_feature_intended.html' %}#developing-intended-configuration-templates">
developing intended configuration templates
</a> documentation for more information.
</p>
<p>
<strong>Note:</strong>
This will perform a <code>git pull</code> on the golden config Jinja template repository to ensure the latest templates are used.
</p>
{% render_field form.device %}
{% render_field form.git_repository %}
</div>
</div>
<div class="button-container text-right">
<button type="submit" class="btn btn-primary">Render</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
<div class="col-lg-6 col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Intended Configuration</strong>
<button type="button" class="btn btn-inline btn-default copy-rendered-config" data-clipboard-target="#rendered_config">
<span class="mdi mdi-content-copy"></span>
</button>
</div>
<div class="panel-body">
<textarea readonly="readonly" cols="40" rows="10" class="form-control" placeholder="Rendered Config" id="rendered_config"></textarea>
</div>
</div>
</div>
</div>
</form>
{% endblock content %}

{% block javascript %}
{{ block.super }}
<script>
new ClipboardJS('.copy-rendered-config');
const sanitize = function(string) {
return string.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
};
async function handleFormSubmit(event) {
event.preventDefault(); // Prevent default form submission

try {
const rendered_config = document.getElementById("rendered_config");
rendered_config.innerHTML = "Loading...";
const device = document.getElementById("id_device").value;
const url = "{% url 'plugins-api:nautobot_golden_config-api:generate_intended_config' %}";
const data = {device_id: device};
const query_params = new URLSearchParams(data).toString();
const response = await fetch(url + "?" + query_params, {
method: "GET",
headers: {"Content-Type": "application/json"}
});
const responseData = await response.json();
if (!response.ok) {
const msg = responseData.detail ? responseData.detail : response.statusText;
rendered_config.innerHTML = sanitize(`An error occurred:\n\n${msg}`);
} else {
rendered_config.innerHTML = sanitize(responseData.intended_config);
}
} catch (error) {
rendered_config.innerHTML = sanitize(`An error occurred:\n\n${error.message}`);
}
}
</script>
{% endblock javascript %}
53 changes: 22 additions & 31 deletions nautobot_golden_config/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Unit tests for nautobot_golden_config."""

import uuid
from copy import deepcopy
from unittest.mock import patch

Expand Down Expand Up @@ -431,15 +430,15 @@ def setUpTestData(cls):
platform.network_driver = "arista_eos"
platform.save()

cls.git_repository = GitRepository.objects.get(name="test-jinja-repo-1")
cls.golden_config_setting = GoldenConfigSetting.objects.create(
name="GoldenConfigSetting test api generate intended config",
slug="goldenconfigsetting-test-api-generate-intended-config",
sot_agg_query=GraphQLQuery.objects.get(name="GC-SoTAgg-Query-2"),
dynamic_group=cls.dynamic_group,
jinja_repository=cls.git_repository,
)

cls.git_repository = GitRepository.objects.get(name="test-jinja-repo-1")

def _setup_mock_path(self, MockPath): # pylint: disable=invalid-name
mock_path_instance = MockPath.return_value
mock_path_instance.__str__.return_value = "test.j2"
Expand Down Expand Up @@ -474,7 +473,7 @@ def _generate_config(task, *args, **kwargs):

response = self.client.get(
reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"),
data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk},
data={"device_id": self.device.pk},
**self.header,
)

Expand All @@ -499,7 +498,7 @@ def test_generate_intended_config_failures(self, mock_dispatcher, MockPath, mock
# test missing query parameters
response = self.client.get(
reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"),
data={"git_repository_id": self.git_repository.pk},
data={},
**self.header,
)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
Expand All @@ -509,24 +508,12 @@ def test_generate_intended_config_failures(self, mock_dispatcher, MockPath, mock
"Parameter device_id is required",
)

response = self.client.get(
reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"),
data={"device_id": self.device.pk},
**self.header,
)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertTrue("detail" in response.data)
self.assertEqual(
response.data["detail"],
"Parameter git_repository_id is required",
)

# test git repo not present on filesystem
mock_path_instance.is_file.return_value = False

response = self.client.get(
reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"),
data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk},
data={"device_id": self.device.pk},
**self.header,
)

Expand Down Expand Up @@ -556,58 +543,62 @@ def _generate_config(task, *args, **kwargs):

response = self.client.get(
reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"),
data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk},
data={"device_id": self.device.pk},
**self.header,
)

self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertTrue("detail" in response.data)
self.assertEqual("Error rendering Jinja template", response.data["detail"])
self.assertEqual(
response.data["detail"].strip(),
"Error rendering Jinja template: Subtask: GENERATE CONFIG (failed)",
)

# test ensure_git_repository failure
mock_ensure_git_repository.side_effect = Exception("Test exception")

response = self.client.get(
reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"),
data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk},
data={"device_id": self.device.pk},
**self.header,
)

self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertTrue("detail" in response.data)
self.assertEqual("Error trying to sync git repository", response.data["detail"])

# test no sot_agg_query on GoldenConfigSetting
self.golden_config_setting.sot_agg_query = None
# test jinja_repository not set
self.golden_config_setting.jinja_repository = None
self.golden_config_setting.save()

response = self.client.get(
reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"),
data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk},
data={"device_id": self.device.pk},
**self.header,
)

self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertTrue("detail" in response.data)
self.assertEqual("Golden Config settings sot_agg_query not set", response.data["detail"])
self.assertEqual(response.data["detail"], "Golden Config settings jinja_repository not set")

# test no sot_agg_query on GoldenConfigSetting
self.golden_config_setting.sot_agg_query = None
self.golden_config_setting.save()

# test git_repository instance not found
invalid_uuid = uuid.uuid4()
response = self.client.get(
reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"),
data={"device_id": self.device.pk, "git_repository_id": invalid_uuid},
data={"device_id": self.device.pk},
**self.header,
)

self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertTrue("detail" in response.data)
self.assertEqual(f"GitRepository with id '{invalid_uuid}' not found", response.data["detail"])
self.assertEqual("Golden Config settings sot_agg_query not set", response.data["detail"])

# test no GoldenConfigSetting found for device
GoldenConfigSetting.objects.all().delete()
response = self.client.get(
reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"),
data={"device_id": self.device.pk, "git_repository_id": self.git_repository.pk},
data={"device_id": self.device.pk},
**self.header,
)

Expand Down
1 change: 1 addition & 0 deletions nautobot_golden_config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@
urlpatterns = [
path("config-compliance/overview/", views.ConfigComplianceOverview.as_view(), name="configcompliance_overview"),
path("config-plan/bulk_deploy/", views.ConfigPlanBulkDeploy.as_view(), name="configplan_bulk-deploy"),
path("generate-intended-config/", views.GenerateIntendedConfigView.as_view(), name="generate_intended_config"),
path("docs/", RedirectView.as_view(url=static("nautobot_golden_config/docs/index.html")), name="docs"),
] + router.urls
Loading

0 comments on commit e14792f

Please sign in to comment.