diff --git a/docs/features/configuration-rendering.md b/docs/features/configuration-rendering.md index 2fe43f046..9d212a34e 100644 --- a/docs/features/configuration-rendering.md +++ b/docs/features/configuration-rendering.md @@ -12,7 +12,7 @@ click ConfigTemplate "../../models/extras/configtemplate/" ## Configuration Templates -Configuration templates are written in the [Jinja2 templating language](https://jinja.palletsprojects.com/), and may be automatically populated from remote data sources. Context data is applied to a template during rendering to output a complete configuration file. Below is an example template. +Configuration templates are written in the [Jinja2 templating language](https://jinja.palletsprojects.com/), and may be automatically populated from remote data sources. Context data is applied to a template during rendering to output a complete configuration file. Below is an example Jinja2 template which renders a simple network switch configuration file. ```jinja2 {% extends 'base.j2' %} @@ -36,3 +36,44 @@ Configuration templates are written in the [Jinja2 templating language](https:// ``` When rendered for a specific NetBox device, the template's `device` variable will be populated with the device instance, and `ntp_servers` will be pulled from the device's available context data. The resulting output will be a valid configuration segment that can be applied directly to a compatible network device. + +## Rendering Templates + +### Device Configurations + +NetBox provides a REST API endpoint specifically for rendering the default configuration template for a specific device. This is accomplished by sending a POST request to the device's unique URL, optionally including additional context data. + +```no-highlight +curl -X POST \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +http://netbox:8000/api/dcim/devices/123/render-config/ \ +--data '{ + "extra_data": "abc123" +}' +``` + +This request will trigger resolution of the device's preferred config template in the following order: + +* The config template assigned to the individual device +* The config template assigned to the device's role +* The config template assigned to the device's platform + +If no config template has been assigned to any of these three objects, the request will fail. + +### General Purpose Use + +NetBox config templates can also be rendered without being tied to any specific device, using a separate general purpose REST API endpoint. Any data included with a POST request to this endpoint will be passed as context data for the template. + +```no-highlight +curl -X POST \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +http://netbox:8000/api/extras/config-templates/123/render/ \ +--data '{ + "foo": "abc", + "bar": 123 +}' +``` diff --git a/docs/models/extras/configtemplate.md b/docs/models/extras/configtemplate.md index c3585dbdf..b580d6885 100644 --- a/docs/models/extras/configtemplate.md +++ b/docs/models/extras/configtemplate.md @@ -1,6 +1,6 @@ # Configuration Templates -Configuration templates can be used to render [devices](../dcim/device.md) configurations from [context data](../../features/context-data.md). Templates are written in the [Jinja2 language](https://jinja.palletsprojects.com/) and can be associated with devices roles, platforms, and/or individual devices. +Configuration templates can be used to render [device](../dcim/device.md) configurations from [context data](../../features/context-data.md). Templates are written in the [Jinja2 language](https://jinja.palletsprojects.com/) and can be associated with devices roles, platforms, and/or individual devices. Context data is made available to [devices](../dcim/device.md) and/or [virtual machines](../virtualization/virtualmachine.md) based on their relationships to other objects in NetBox. For example, context data can be associated only with devices assigned to a particular site, or only to virtual machines in a certain cluster. diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f4a2af37a..d3a0f99ae 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -604,6 +604,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer): class DeviceRoleSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') + config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -618,6 +619,7 @@ class DeviceRoleSerializer(NetBoxModelSerializer): class PlatformSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) + config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 917debd14..37151b96c 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,12 +1,12 @@ -import socket - -from django.http import Http404, HttpResponse, HttpResponseForbidden +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.openapi import Parameter from drf_yasg.utils import swagger_auto_schema from rest_framework.decorators import action +from rest_framework.renderers import JSONRenderer from rest_framework.response import Response +from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet @@ -15,14 +15,14 @@ from dcim import filtersets from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.models import * from dcim.svg import CableTraceSVG -from extras.api.views import ConfigContextQuerySetMixin +from extras.api.nested_serializers import NestedConfigTemplateSerializer +from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired -from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import StripCountAnnotationsPaginator +from netbox.api.renderers import TextRenderer from netbox.api.viewsets import NetBoxModelViewSet -from netbox.config import get_config from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model from utilities.utils import count_related @@ -391,10 +391,10 @@ class PlatformViewSet(NetBoxModelViewSet): # Devices/modules # -class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): +class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet): queryset = Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', - 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', + 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags', ) filterset_class = filtersets.DeviceFilterSet pagination_class = StripCountAnnotationsPaginator @@ -419,6 +419,19 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): return serializers.DeviceWithConfigContextSerializer + @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer]) + def render_config(self, request, pk): + """ + Resolve and render the preferred ConfigTemplate for this Device. + """ + device = self.get_object() + configtemplate = device.get_config_template() + if not configtemplate: + return Response({'error': 'No config template found for this device.'}, status=HTTP_400_BAD_REQUEST) + context = {**request.data, 'device': device} + + return self.render_configtemplate(request, configtemplate, context) + class VirtualDeviceContextViewSet(NetBoxModelViewSet): queryset = VirtualDeviceContext.objects.prefetch_related( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4f3ce23e8..9cf50713f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1987,6 +1987,17 @@ class DeviceInventoryView(DeviceComponentsView): ) +@register_model_view(Device, 'configcontext', path='config-context') +class DeviceConfigContextView(ObjectConfigContextView): + queryset = Device.objects.annotate_config_context_data() + base_template = 'dcim/device/base.html' + tab = ViewTab( + label=_('Config Context'), + permission='extras.view_configcontext', + weight=2000 + ) + + @register_model_view(Device, 'render-config') class DeviceRenderConfigView(generic.ObjectView): queryset = Device.objects.all() @@ -1994,7 +2005,7 @@ class DeviceRenderConfigView(generic.ObjectView): tab = ViewTab( label=_('Render Config'), permission='extras.view_configtemplate', - weight=2000 + weight=2100 ) def get_extra_context(self, request, instance): @@ -2020,17 +2031,6 @@ class DeviceRenderConfigView(generic.ObjectView): } -@register_model_view(Device, 'configcontext', path='config-context') -class DeviceConfigContextView(ObjectConfigContextView): - queryset = Device.objects.annotate_config_context_data() - base_template = 'dcim/device/base.html' - tab = ViewTab( - label=_('Config Context'), - permission='extras.view_configcontext', - weight=2100 - ) - - class DeviceBulkImportView(generic.BulkImportView): queryset = Device.objects.all() model_form = forms.DeviceImportForm diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py new file mode 100644 index 000000000..d325e4f90 --- /dev/null +++ b/netbox/extras/api/mixins.py @@ -0,0 +1,46 @@ +from rest_framework.response import Response + +from .nested_serializers import NestedConfigTemplateSerializer + +__all__ = ( + 'ConfigContextQuerySetMixin', +) + + +class ConfigContextQuerySetMixin: + """ + Used by views that work with config context models (device and virtual machine). + Provides a get_queryset() method which deals with adding the config context + data annotation or not. + """ + def get_queryset(self): + """ + Build the proper queryset based on the request context + + If the `brief` query param equates to True or the `exclude` query param + includes `config_context` as a value, return the base queryset. + + Else, return the queryset annotated with config context data + """ + queryset = super().get_queryset() + request = self.get_serializer_context()['request'] + if self.brief or 'config_context' in request.query_params.get('exclude', []): + return queryset + return queryset.annotate_config_context_data() + + +class ConfigTemplateRenderMixin: + + def render_configtemplate(self, request, configtemplate, context): + output = configtemplate.render(context=context) + + # If the client has requested "text/plain", return the raw content. + if request.accepted_renderer.format == 'txt': + return Response(output) + + template_serializer = NestedConfigTemplateSerializer(configtemplate, context={'request': request}) + + return Response({ + 'configtemplate': template_serializer.data, + 'content': output + }) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 7665e949d..dfee44a51 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -26,6 +26,7 @@ from netbox.api.viewsets import NetBoxModelViewSet from utilities.exceptions import RQWorkerNotRunningException from utilities.utils import copy_safe_request, count_related from . import serializers +from .mixins import ConfigTemplateRenderMixin from .nested_serializers import NestedConfigTemplateSerializer @@ -37,28 +38,6 @@ class ExtrasRootView(APIRootView): return 'Extras' -class ConfigContextQuerySetMixin: - """ - Used by views that work with config context models (device and virtual machine). - Provides a get_queryset() method which deals with adding the config context - data annotation or not. - """ - def get_queryset(self): - """ - Build the proper queryset based on the request context - - If the `brief` query param equates to True or the `exclude` query param - includes `config_context` as a value, return the base queryset. - - Else, return the queryset annotated with config context data - """ - queryset = super().get_queryset() - request = self.get_serializer_context()['request'] - if self.brief or 'config_context' in request.query_params.get('exclude', []): - return queryset - return queryset.annotate_config_context_data() - - # # Webhooks # @@ -165,7 +144,7 @@ class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet): # Config templates # -class ConfigTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet): +class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet): queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file') serializer_class = serializers.ConfigTemplateSerializer filterset_class = filtersets.ConfigTemplateFilterSet @@ -177,17 +156,9 @@ class ConfigTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet): return the raw rendered content, rather than serialized JSON. """ configtemplate = self.get_object() - output = configtemplate.render(context=request.data) + context = request.data - # If the client has requested "text/plain", return the raw content. - if request.accepted_renderer.format == 'txt': - return Response(output) - - template_serializer = NestedConfigTemplateSerializer(configtemplate, context={'request': request}) - return Response({ - 'configtemplate': template_serializer.data, - 'content': output - }) + return self.render_configtemplate(request, configtemplate, context) # diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 75eedba48..0ea8daa8e 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -306,12 +306,26 @@ class ConfigTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): fieldsets = ( ('Config Template', ('name', 'description', 'environment_params', 'tags')), - ('Content', ('data_source', 'data_file', 'template_code',)), + ('Content', ('template_code',)), + ('Data Source', ('data_source', 'data_file')), ) class Meta: model = ConfigTemplate fields = '__all__' + widgets = { + 'environment_params': forms.Textarea(attrs={'rows': 5}) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable content field when a DataFile has been set + if self.instance.data_file: + self.fields['template_code'].widget.attrs['readonly'] = True + self.fields['template_code'].help_text = _( + 'Template content is populated from the remote source selected below.' + ) def clean(self): super().clean() diff --git a/netbox/extras/migrations/0086_configtemplate.py b/netbox/extras/migrations/0086_configtemplate.py index bd47254e9..82f2b38a3 100644 --- a/netbox/extras/migrations/0086_configtemplate.py +++ b/netbox/extras/migrations/0086_configtemplate.py @@ -22,7 +22,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=100)), ('description', models.CharField(blank=True, max_length=200)), ('template_code', models.TextField()), - ('environment_params', models.JSONField(blank=True, null=True)), + ('environment_params', models.JSONField(blank=True, default=dict, null=True)), ('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')), ('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 731288139..bc4e7258d 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -209,7 +209,12 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog ) environment_params = models.JSONField( blank=True, - null=True + null=True, + default=dict, + help_text=_( + 'Any additional parameters' + ' to pass when constructing the Jinja2 environment.' + ) ) class Meta: @@ -235,11 +240,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog # Initialize the Jinja2 environment and instantiate the Template environment = self._get_environment() - if self.data_file: - template = environment.get_template(self.data_file.path) - else: - template = environment.from_string(self.template_code) - + template = environment.from_string(self.template_code) output = template.render(**context) # Replace CRLF-style line terminators @@ -259,7 +260,8 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog loader = BaseLoader() # Initialize the environment - environment = SandboxedEnvironment(loader=loader) + env_params = self.environment_params or {} + environment = SandboxedEnvironment(loader=loader, **env_params) environment.filters.update(get_config().JINJA2_FILTERS) return environment diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index d2a90ae34..5b9cf4117 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,7 +1,7 @@ from rest_framework.routers import APIRootView from dcim.models import Device -from extras.api.views import ConfigContextQuerySetMixin +from extras.api.mixins import ConfigContextQuerySetMixin from netbox.api.viewsets import NetBoxModelViewSet from utilities.utils import count_related from virtualization import filtersets