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

Add branch parameter to generate intended config view #871

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
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/828.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `branch` parameter to generate intended config view.
Binary file modified 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 modified 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.
32 changes: 21 additions & 11 deletions docs/user/app_feature_intended.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,36 @@ 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 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.
To generate a device's intended configuration without running a full "intended configuration" job, Golden Config provides a simple web UI at `/plugins/golden-config/generate-intended-config/` and a REST API at `/api/plugins/golden-config/generate-intended-config/`.

Here's an example of how to request the rendered configuration for a device using the REST API:
Note that this tool is only intended to render Jinja2 templates and does not apply any [configuration post-processing](./app_feature_config_postprocessing.md).

```no-highlight
curl -s -X GET \
-H "Accept: application/json" \
http://nautobot/api/plugins/golden-config/generate-intended-config/?device_id=231b8765-054d-4abe-bdbf-cd60e049cd8d
```
Using this tool to render a configuration will automatically retrieve the latest commit from the Jinja2 templates Git repository before rendering the template.

#### Web UI

The returned response will contain the rendered configuration for the specified device, the GraphQL data that was used, and if applicable, a diff of the most recent intended config that was generated by the **Intended Configuration** job. The web UI provides a simple form to interact with this REST API. You can access the web UI by clicking on "Generate Intended Config" in the "Tools" section of the Golden Config navigation menu.
The web UI provides a user-friendly form to interact with the rendering process. You can access the web UI by clicking on "Generate Intended Config" in the "Tools" section of the Golden Config navigation menu.

For more advanced use cases, the REST API and web UI also accept a `graphql_query_id` parameter to specify a custom GraphQL query to use when rendering the configuration. If a `graphql_query_id` is not provided, the default query configured in the Device's Golden Config settings will be used.
For more advanced use cases, the form accepts an optional "GraphQL Query" to specify a custom GraphQL query to use when rendering the configuration. If a "GraphQL Query" is not provided, the default query configured in the Device's Golden Config settings will be used.

Starting in Nautobot v2.4.2, this UI also allows you to supply a "Git Repository Branch" to specify the branch of the Jinja2 templates Git repository to use when rendering the configuration. If the branch is not provided, the configured branch of the Golden Config Setting's Jinja template Git repository will be used.

![Intended Configuration Web UI](../images/generate-intended-config-ui.png#only-light)
![Intended Configuration Web UI](../images/generate-intended-config-ui-dark.png#only-dark)

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

The REST API accepts query parameters for `device_id`, an optional `graphql_query_id` and an optional `branch` if running Nautobot v2.4.2 or later.

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

```no-highlight
curl -s -X GET \
-H "Accept: application/json" \
http://nautobot/api/plugins/golden-config/generate-intended-config/?device_id=231b8765-054d-4abe-bdbf-cd60e049cd8d
```

Note that this API is only intended to render Jinja2 templates and does not apply any [configuration post-processing](./app_feature_config_postprocessing.md).
The returned response will contain the rendered configuration for the specified device, the GraphQL data that was used, and if applicable, a diff of the most recent intended config that was generated by the **Intended Configuration** job.

## Adding Jinja2 Filters to the Environment.

Expand Down
26 changes: 26 additions & 0 deletions nautobot_golden_config/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

# pylint: disable=too-many-ancestors
from nautobot.apps.api import NautobotModelSerializer, TaggedModelSerializerMixin
from nautobot.apps.utils import GitRepo
from nautobot.dcim.api.serializers import DeviceSerializer
from nautobot.dcim.models import Device
from nautobot.extras.api.serializers import GitRepositorySerializer
from nautobot.extras.datasources.git import ensure_git_repository, get_repo_from_url_to_path_and_from_branch
from nautobot.extras.models import GitRepository
from rest_framework import serializers

from nautobot_golden_config import models
Expand Down Expand Up @@ -136,3 +140,25 @@ class GenerateIntendedConfigSerializer(serializers.Serializer): # pylint: disab
graphql_data = serializers.JSONField(read_only=True)
diff = serializers.CharField(read_only=True)
diff_lines = serializers.ListField(read_only=True, child=serializers.CharField())


class GitRepositoryWithBranchesSerializer(GitRepositorySerializer): # pylint: disable=nb-sub-class-name
"""Serializer for extras.GitRepository with remote branches field."""

remote_branches = serializers.SerializerMethodField()

def get_remote_branches(self, obj):
"""Return a list of branches for the GitRepository."""
ensure_git_repository(obj)
from_url, to_path, _ = get_repo_from_url_to_path_and_from_branch(obj)
repo_helper = GitRepo(to_path, from_url)
repo_helper.repo.remotes.origin.fetch()
return [
ref.name[7:] # removeprefix("origin/")
for ref in repo_helper.repo.remotes.origin.refs
if ref.name != "origin/HEAD"
]

class Meta: # noqa: D106 # undocumented-public-nested-class
model = GitRepository
fields = "__all__"
5 changes: 5 additions & 0 deletions nautobot_golden_config/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,10 @@
views.GenerateIntendedConfigView.as_view(),
name="generate_intended_config",
),
path(
"git-repository-branches/<pk>/",
views.GitRepositoryBranchesView.as_view(),
name="git_repository_branches",
),
]
urlpatterns += router.urls
68 changes: 54 additions & 14 deletions nautobot_golden_config/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
from pathlib import Path

from django.conf import settings as nautobot_settings
from django.contrib.contenttypes.models import ContentType
from django.utils.timezone import make_aware
from drf_spectacular.types import OpenApiTypes
Expand All @@ -21,13 +22,14 @@
)
from nautobot.dcim.models import Device
from nautobot.extras.datasources.git import ensure_git_repository
from nautobot.extras.models import GraphQLQuery
from nautobot.extras.models import GitRepository, GraphQLQuery
from nautobot_plugin_nornir.constants import NORNIR_SETTINGS
from nornir import InitNornir
from nornir_nautobot.plugins.tasks.dispatcher import dispatcher
from packaging import version
from rest_framework import mixins, status, viewsets
from rest_framework.exceptions import APIException
from rest_framework.generics import GenericAPIView
from rest_framework.generics import GenericAPIView, RetrieveAPIView
from rest_framework.mixins import DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
from rest_framework.permissions import AllowAny, BasePermission, IsAuthenticated
from rest_framework.response import Response
Expand Down Expand Up @@ -237,20 +239,29 @@ def _get_object(self, request, model, query_param):
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):
def _get_jinja_template_path(self, settings, device, git_repository, base_path=None):
"""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("Error rendering Jinja path template") from exc
filesystem_path = Path(git_repository.filesystem_path) / rendered_path
if base_path is None:
filesystem_path = Path(git_repository.filesystem_path) / rendered_path
else:
filesystem_path = Path(base_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 {rendered_path} not found in git repository {git_repository}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this change, should be easier for the user to understand.

raise GenerateIntendedConfigException(msg)
return filesystem_path

@extend_schema(
parameters=[
OpenApiParameter(
name="branch",
required=False,
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
),
OpenApiParameter(
name="device_id",
required=True,
Expand All @@ -265,9 +276,12 @@ def _get_jinja_template_path(self, settings, device, git_repository):
),
]
)
def get(self, request, *args, **kwargs):
def get(self, request, *args, **kwargs): # pylint: disable=too-many-locals, too-many-branches
"""Generate intended configuration for a Device."""
device = self._get_object(request, Device, "device_id")
branch_param = request.query_params.get("branch")
if branch_param and version.parse(nautobot_settings.VERSION) < version.parse("2.4.2"):
raise GenerateIntendedConfigException("Branch support requires Nautobot v2.4.2 or later")
graphql_query = None
graphql_query_id_param = request.query_params.get("graphql_query_id")
if graphql_query_id_param:
Expand Down Expand Up @@ -298,17 +312,28 @@ def get(self, request, *args, **kwargs):
except Exception as exc:
raise GenerateIntendedConfigException("Error trying to sync git repository") from exc

filesystem_path = self._get_jinja_template_path(settings, device, git_repository)

status_code, graphql_data = graph_ql_query(request, device, graphql_query.query)
if status_code == status.HTTP_200_OK:
try:
intended_config = self._render_config_nornir_serial(
device=device,
jinja_template=filesystem_path.name,
jinja_root_path=filesystem_path.parent,
graphql_data=graphql_data,
)
if branch_param:
with git_repository.clone_to_directory_context(branch=branch_param) as git_repo_path:
filesystem_path = self._get_jinja_template_path(
settings, device, git_repository, base_path=git_repo_path
)
intended_config = self._render_config_nornir_serial(
device=device,
jinja_template=filesystem_path.name,
jinja_root_path=filesystem_path.parent,
graphql_data=graphql_data,
)
else:
filesystem_path = self._get_jinja_template_path(settings, device, git_repository)
intended_config = self._render_config_nornir_serial(
device=device,
jinja_template=filesystem_path.name,
jinja_root_path=filesystem_path.parent,
graphql_data=graphql_data,
)
except Exception as exc:
raise GenerateIntendedConfigException(f"Error rendering Jinja template: {exc}") from exc

Expand Down Expand Up @@ -372,3 +397,18 @@ def _render_config_nornir_serial(self, device, jinja_template, jinja_root_path,
)
else:
return results[device.name][1][1][0].result["config"]


@extend_schema(exclude=True)
class GitRepositoryBranchesView(NautobotAPIVersionMixin, RetrieveAPIView):
"""API view for extras.GitRepository with branches."""

name = "Git Repository with Branches"
permission_classes = [IsAuthenticated]
queryset = GitRepository.objects.all()
serializer_class = serializers.GitRepositoryWithBranchesSerializer

def get_queryset(self):
"""Override the original get_queryset to apply permissions."""
queryset = super().get_queryset()
return queryset.restrict(self.request.user, "view")
9 changes: 9 additions & 0 deletions nautobot_golden_config/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import json

import django.forms as django_forms
from django.conf import settings
from nautobot.apps import forms
from nautobot.dcim.models import Device, DeviceType, Location, Manufacturer, Platform, Rack, RackGroup
from nautobot.extras.forms import NautobotBulkEditForm, NautobotFilterForm, NautobotModelForm
from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, JobResult, Role, Status, Tag
from nautobot.tenancy.models import Tenant, TenantGroup
from packaging import version

from nautobot_golden_config import models
from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice, ConfigPlanTypeChoice, RemediationTypeChoice
Expand Down Expand Up @@ -614,3 +616,10 @@ class GenerateIntendedConfigForm(django_forms.Form):
label="GraphQL Query",
query_params={"nautobot_golden_config_graphql_query_variables": "device_id"},
)
git_repository_branch = django_forms.ChoiceField(widget=forms.StaticSelect2)

def __init__(self, *args, **kwargs):
"""Conditionally hide the git_repository_branch field based on Nautobot version."""
super().__init__(*args, **kwargs)
if version.parse(settings.VERSION) < version.parse("2.4.2"):
self.fields["git_repository_branch"].widget = django_forms.HiddenInput
Loading
Loading