Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added a web ui to render intended configurations #827

Merged
merged 7 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
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