1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

#11559: Add device config API endpoint & cleanup

This commit is contained in:
jeremystretch
2023-03-21 17:00:06 -04:00
parent d6afc125e5
commit 00088cba6d
11 changed files with 154 additions and 65 deletions

View File

@ -12,7 +12,7 @@ click ConfigTemplate "../../models/extras/configtemplate/"
## Configuration Templates ## 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 ```jinja2
{% extends 'base.j2' %} {% 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. 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
}'
```

View File

@ -1,6 +1,6 @@
# Configuration Templates # 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. 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.

View File

@ -604,6 +604,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
class DeviceRoleSerializer(NetBoxModelSerializer): class DeviceRoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') 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) device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True)
@ -618,6 +619,7 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
class PlatformSerializer(NetBoxModelSerializer): class PlatformSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True)

View File

@ -1,12 +1,12 @@
import socket from django.http import Http404, HttpResponse
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_yasg import openapi from drf_yasg import openapi
from drf_yasg.openapi import Parameter from drf_yasg.openapi import Parameter
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.viewsets import ViewSet 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.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import * from dcim.models import *
from dcim.svg import CableTraceSVG 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 ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from netbox.config import get_config
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.utils import count_related from utilities.utils import count_related
@ -391,10 +391,10 @@ class PlatformViewSet(NetBoxModelViewSet):
# Devices/modules # Devices/modules
# #
class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', '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 filterset_class = filtersets.DeviceFilterSet
pagination_class = StripCountAnnotationsPaginator pagination_class = StripCountAnnotationsPaginator
@ -419,6 +419,19 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
return serializers.DeviceWithConfigContextSerializer 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): class VirtualDeviceContextViewSet(NetBoxModelViewSet):
queryset = VirtualDeviceContext.objects.prefetch_related( queryset = VirtualDeviceContext.objects.prefetch_related(

View File

@ -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') @register_model_view(Device, 'render-config')
class DeviceRenderConfigView(generic.ObjectView): class DeviceRenderConfigView(generic.ObjectView):
queryset = Device.objects.all() queryset = Device.objects.all()
@ -1994,7 +2005,7 @@ class DeviceRenderConfigView(generic.ObjectView):
tab = ViewTab( tab = ViewTab(
label=_('Render Config'), label=_('Render Config'),
permission='extras.view_configtemplate', permission='extras.view_configtemplate',
weight=2000 weight=2100
) )
def get_extra_context(self, request, instance): 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): class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all() queryset = Device.objects.all()
model_form = forms.DeviceImportForm model_form = forms.DeviceImportForm

View File

@ -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
})

View File

@ -26,6 +26,7 @@ from netbox.api.viewsets import NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException from utilities.exceptions import RQWorkerNotRunningException
from utilities.utils import copy_safe_request, count_related from utilities.utils import copy_safe_request, count_related
from . import serializers from . import serializers
from .mixins import ConfigTemplateRenderMixin
from .nested_serializers import NestedConfigTemplateSerializer from .nested_serializers import NestedConfigTemplateSerializer
@ -37,28 +38,6 @@ class ExtrasRootView(APIRootView):
return 'Extras' 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 # Webhooks
# #
@ -165,7 +144,7 @@ class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
# Config templates # Config templates
# #
class ConfigTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet): class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file') queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file')
serializer_class = serializers.ConfigTemplateSerializer serializer_class = serializers.ConfigTemplateSerializer
filterset_class = filtersets.ConfigTemplateFilterSet filterset_class = filtersets.ConfigTemplateFilterSet
@ -177,17 +156,9 @@ class ConfigTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
return the raw rendered content, rather than serialized JSON. return the raw rendered content, rather than serialized JSON.
""" """
configtemplate = self.get_object() configtemplate = self.get_object()
output = configtemplate.render(context=request.data) context = request.data
# If the client has requested "text/plain", return the raw content. return self.render_configtemplate(request, configtemplate, context)
if request.accepted_renderer.format == 'txt':
return Response(output)
template_serializer = NestedConfigTemplateSerializer(configtemplate, context={'request': request})
return Response({
'configtemplate': template_serializer.data,
'content': output
})
# #

View File

@ -306,12 +306,26 @@ class ConfigTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
fieldsets = ( fieldsets = (
('Config Template', ('name', 'description', 'environment_params', 'tags')), ('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: class Meta:
model = ConfigTemplate model = ConfigTemplate
fields = '__all__' 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): def clean(self):
super().clean() super().clean()

View File

@ -22,7 +22,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('description', models.CharField(blank=True, max_length=200)), ('description', models.CharField(blank=True, max_length=200)),
('template_code', models.TextField()), ('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_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')), ('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')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),

View File

@ -209,7 +209,12 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
) )
environment_params = models.JSONField( environment_params = models.JSONField(
blank=True, blank=True,
null=True null=True,
default=dict,
help_text=_(
'Any <a href="https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment">additional parameters</a>'
' to pass when constructing the Jinja2 environment.'
)
) )
class Meta: class Meta:
@ -235,11 +240,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
# Initialize the Jinja2 environment and instantiate the Template # Initialize the Jinja2 environment and instantiate the Template
environment = self._get_environment() environment = self._get_environment()
if self.data_file: template = environment.from_string(self.template_code)
template = environment.get_template(self.data_file.path)
else:
template = environment.from_string(self.template_code)
output = template.render(**context) output = template.render(**context)
# Replace CRLF-style line terminators # Replace CRLF-style line terminators
@ -259,7 +260,8 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
loader = BaseLoader() loader = BaseLoader()
# Initialize the environment # 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) environment.filters.update(get_config().JINJA2_FILTERS)
return environment return environment

View File

@ -1,7 +1,7 @@
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from dcim.models import Device from dcim.models import Device
from extras.api.views import ConfigContextQuerySetMixin from extras.api.mixins import ConfigContextQuerySetMixin
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from utilities.utils import count_related from utilities.utils import count_related
from virtualization import filtersets from virtualization import filtersets