Bladeren bron

Closes #11559: Implement config template rendering (#11769)

* WIP

* Add config_template field to Device

* Pre-fetch referenced templates

* Correct up_to_date callable

* Add config_template FK to Device

* Update & merge migrations

* Add config_template FK to Platform

* Add tagging support for ConfigTemplate

* Catch exceptions when rendering device templates in UI

* Refactor ConfigTemplate.render()

* Add support for returning plain text content

* Add ConfigTemplate model documentation

* Add feature documentation for config rendering
Jeremy Stretch 3 jaren geleden
bovenliggende
commit
73a7a2d27a
45 gewijzigde bestanden met toevoegingen van 886 en 36 verwijderingen
  1. 38 0
      docs/features/configuration-rendering.md
  2. 2 0
      docs/features/context-data.md
  3. 4 0
      docs/models/dcim/device.md
  4. 4 0
      docs/models/dcim/devicerole.md
  5. 4 0
      docs/models/dcim/platform.md
  6. 29 0
      docs/models/extras/configtemplate.md
  7. 2 0
      mkdocs.yml
  8. 7 5
      netbox/dcim/api/serializers.py
  9. 2 2
      netbox/dcim/api/views.py
  10. 13 0
      netbox/dcim/filtersets.py
  11. 18 5
      netbox/dcim/forms/bulk_edit.py
  12. 24 3
      netbox/dcim/forms/bulk_import.py
  13. 17 1
      netbox/dcim/forms/filtersets.py
  14. 18 5
      netbox/dcim/forms/model_forms.py
  15. 28 0
      netbox/dcim/migrations/0170_configtemplate.py
  16. 32 0
      netbox/dcim/models/devices.py
  17. 14 5
      netbox/dcim/tables/devices.py
  18. 38 2
      netbox/dcim/views.py
  19. 9 0
      netbox/extras/api/nested_serializers.py
  20. 23 0
      netbox/extras/api/serializers.py
  21. 1 0
      netbox/extras/api/urls.py
  22. 32 0
      netbox/extras/api/views.py
  23. 31 2
      netbox/extras/filtersets.py
  24. 14 0
      netbox/extras/forms/bulk_edit.py
  25. 10 0
      netbox/extras/forms/bulk_import.py
  26. 22 0
      netbox/extras/forms/filtersets.py
  27. 29 0
      netbox/extras/forms/model_forms.py
  28. 3 0
      netbox/extras/graphql/schema.py
  29. 9 0
      netbox/extras/graphql/types.py
  30. 34 0
      netbox/extras/migrations/0086_configtemplate.py
  31. 2 1
      netbox/extras/models/__init__.py
  32. 81 1
      netbox/extras/models/configs.py
  33. 29 0
      netbox/extras/tables/tables.py
  34. 8 0
      netbox/extras/urls.py
  35. 52 0
      netbox/extras/views.py
  36. 17 1
      netbox/netbox/api/renderers.py
  37. 1 0
      netbox/netbox/navigation/menu.py
  38. 5 3
      netbox/templates/dcim/device.html
  39. 47 0
      netbox/templates/dcim/device/render_config.html
  40. 1 0
      netbox/templates/dcim/device_edit.html
  41. 4 0
      netbox/templates/dcim/devicerole.html
  42. 4 0
      netbox/templates/dcim/platform.html
  43. 77 0
      netbox/templates/extras/configtemplate.html
  44. 10 0
      netbox/templates/extras/configtemplate_list.html
  45. 37 0
      netbox/utilities/jinja2.py

+ 38 - 0
docs/features/configuration-rendering.md

@@ -0,0 +1,38 @@
+# Configuration Rendering
+
+One of the critical aspects of operating a network is ensuring that every network node is configured correctly. By leveraging configuration templates and [context data](./context-data.md), NetBox can render complete configuration files for each device on your network.
+
+```mermaid
+flowchart TD
+    ConfigContext & ConfigTemplate --> Config{{Rendered configuration}}
+
+click ConfigContext "../../models/extras/configcontext/"
+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.
+
+```jinja2
+{% extends 'base.j2' %}
+
+{% block content %}
+    system {
+        host-name {{ device.name }};
+        domain-name example.com;
+        time-zone UTC;
+        authentication-order [ password radius ];
+        ntp {
+            {% for server in ntp_servers %}
+                server {{ server }};
+            {% endfor %}
+        }
+    }
+    {% for interface in device.interfaces.all() %}
+        {% include 'common/interface.j2' %}
+    {% endfor %}
+{% endblock %}
+```
+
+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.

+ 2 - 0
docs/features/context-data.md

@@ -11,6 +11,8 @@ Configuration context data (or "config contexts" for short) is a powerful featur
 }
 }
 ```
 ```
 
 
+Context data can be consumed by remote API clients, or it can be employed natively to render [configuration templates](./configuration-rendering.md).
+
 Config contexts can be computed for objects based on the following criteria:
 Config contexts can be computed for objects based on the following criteria:
 
 
 | Type          | Devices          | Virtual Machines |
 | Type          | Devices          | Virtual Machines |

+ 4 - 0
docs/models/dcim/device.md

@@ -72,6 +72,10 @@ The device's operational status.
 
 
 A device may be associated with a particular [platform](./platform.md) to indicate its operating system. Note that only platforms assigned to the associated manufacturer (or to no manufacturer) will be available for selection.
 A device may be associated with a particular [platform](./platform.md) to indicate its operating system. Note that only platforms assigned to the associated manufacturer (or to no manufacturer) will be available for selection.
 
 
+### Configuration Template
+
+The [configuration template](../extras/configtemplate.md) from which the configuration for this device can be rendered. If set, this will override any config template referenced by the device's role or platform.
+
 ### Primary IPv4 & IPv6 Addresses
 ### Primary IPv4 & IPv6 Addresses
 
 
 Each device may designate one primary IPv4 address and/or one primary IPv6 address for management purposes.
 Each device may designate one primary IPv4 address and/or one primary IPv6 address for management purposes.

+ 4 - 0
docs/models/dcim/devicerole.md

@@ -19,3 +19,7 @@ The color used when displaying the role in the NetBox UI.
 ### VM Role
 ### VM Role
 
 
 If selected, this role may be assigned to [virtual machines](../virtualization/virtualmachine.md)
 If selected, this role may be assigned to [virtual machines](../virtualization/virtualmachine.md)
+
+### Configuration Template
+
+The default [configuration template](../extras/configtemplate.md) for devices assigned to this role.

+ 4 - 0
docs/models/dcim/platform.md

@@ -22,6 +22,10 @@ A unique URL-friendly identifier. (This value can be used for filtering.)
 
 
 If designated, this platform will be available for use only to devices assigned to this [manufacturer](./manufacturer.md). This can be handy e.g. for limiting network operating systems to use on hardware produced by the relevant vendor. However, it should not be used when defining general-purpose software platforms.
 If designated, this platform will be available for use only to devices assigned to this [manufacturer](./manufacturer.md). This can be handy e.g. for limiting network operating systems to use on hardware produced by the relevant vendor. However, it should not be used when defining general-purpose software platforms.
 
 
+### Configuration Template
+
+The default [configuration template](../extras/configtemplate.md) for devices assigned to this platform.
+
 ### NAPALM Driver
 ### NAPALM Driver
 
 
 The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform.
 The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform.

+ 29 - 0
docs/models/extras/configtemplate.md

@@ -0,0 +1,29 @@
+# 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.
+
+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.
+
+See the [configuration rendering documentation](../../features/configuration-rendering.md) for more information.
+
+## Fields
+
+### Name
+
+A unique human-friendly name.
+
+### Weight
+
+A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
+
+### Data File
+
+Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify template code: It will be populated automatically from the data file.
+
+### Template Code
+
+Jinja2 template code, if being defined locally rather than replicated from a data file.
+
+### Environment Parameters
+
+A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.

+ 2 - 0
mkdocs.yml

@@ -74,6 +74,7 @@ nav:
         - Contacts: 'features/contacts.md'
         - Contacts: 'features/contacts.md'
         - Search: 'features/search.md'
         - Search: 'features/search.md'
         - Context Data: 'features/context-data.md'
         - Context Data: 'features/context-data.md'
+        - Configuration Rendering: 'features/configuration-rendering.md'
         - Change Logging: 'features/change-logging.md'
         - Change Logging: 'features/change-logging.md'
         - Journaling: 'features/journaling.md'
         - Journaling: 'features/journaling.md'
         - Auth & Permissions: 'features/authentication-permissions.md'
         - Auth & Permissions: 'features/authentication-permissions.md'
@@ -196,6 +197,7 @@ nav:
         - Extras:
         - Extras:
             - Branch: 'models/extras/branch.md'
             - Branch: 'models/extras/branch.md'
             - ConfigContext: 'models/extras/configcontext.md'
             - ConfigContext: 'models/extras/configcontext.md'
+            - ConfigTemplate: 'models/extras/configtemplate.md'
             - CustomField: 'models/extras/customfield.md'
             - CustomField: 'models/extras/customfield.md'
             - CustomLink: 'models/extras/customlink.md'
             - CustomLink: 'models/extras/customlink.md'
             - ExportTemplate: 'models/extras/exporttemplate.md'
             - ExportTemplate: 'models/extras/exporttemplate.md'

+ 7 - 5
netbox/dcim/api/serializers.py

@@ -9,6 +9,7 @@ from timezone_field.rest_framework import TimeZoneSerializerField
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
+from extras.api.nested_serializers import NestedConfigTemplateSerializer
 from ipam.api.nested_serializers import (
 from ipam.api.nested_serializers import (
     NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
     NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
     NestedVRFSerializer,
     NestedVRFSerializer,
@@ -605,8 +606,8 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'device_count', 'virtualmachine_count',
+            'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
         ]
 
 
 
 
@@ -619,8 +620,8 @@ class PlatformSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Platform
         model = Platform
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
-            'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+            'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args',
+            'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
         ]
 
 
 
 
@@ -651,6 +652,7 @@ class DeviceSerializer(NetBoxModelSerializer):
     cluster = NestedClusterSerializer(required=False, allow_null=True)
     cluster = NestedClusterSerializer(required=False, allow_null=True)
     virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
     virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
     vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
     vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
+    config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
@@ -658,7 +660,7 @@ class DeviceSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
             'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
             'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
             'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
-            'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
+            'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
 
 
     @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
     @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)

+ 2 - 2
netbox/dcim/api/views.py

@@ -366,7 +366,7 @@ class InventoryItemTemplateViewSet(NetBoxModelViewSet):
 #
 #
 
 
 class DeviceRoleViewSet(NetBoxModelViewSet):
 class DeviceRoleViewSet(NetBoxModelViewSet):
-    queryset = DeviceRole.objects.prefetch_related('tags').annotate(
+    queryset = DeviceRole.objects.prefetch_related('config_template', 'tags').annotate(
         device_count=count_related(Device, 'device_role'),
         device_count=count_related(Device, 'device_role'),
         virtualmachine_count=count_related(VirtualMachine, 'role')
         virtualmachine_count=count_related(VirtualMachine, 'role')
     )
     )
@@ -379,7 +379,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
 #
 #
 
 
 class PlatformViewSet(NetBoxModelViewSet):
 class PlatformViewSet(NetBoxModelViewSet):
-    queryset = Platform.objects.prefetch_related('tags').annotate(
+    queryset = Platform.objects.prefetch_related('config_template', 'tags').annotate(
         device_count=count_related(Device, 'platform'),
         device_count=count_related(Device, 'platform'),
         virtualmachine_count=count_related(VirtualMachine, 'platform')
         virtualmachine_count=count_related(VirtualMachine, 'platform')
     )
     )

+ 13 - 0
netbox/dcim/filtersets.py

@@ -3,6 +3,7 @@ from django.contrib.auth.models import User
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
+from extras.models import ConfigTemplate
 from ipam.models import ASN, L2VPN, IPAddress, VRF
 from ipam.models import ASN, L2VPN, IPAddress, VRF
 from netbox.filtersets import (
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
@@ -776,6 +777,10 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo
 
 
 
 
 class DeviceRoleFilterSet(OrganizationalModelFilterSet):
 class DeviceRoleFilterSet(OrganizationalModelFilterSet):
+    config_template_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ConfigTemplate.objects.all(),
+        label=_('Config template (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
@@ -794,6 +799,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label=_('Manufacturer (slug)'),
         label=_('Manufacturer (slug)'),
     )
     )
+    config_template_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ConfigTemplate.objects.all(),
+        label=_('Config template (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
@@ -936,6 +945,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         method='_virtual_chassis_member',
         method='_virtual_chassis_member',
         label=_('Is a virtual chassis member')
         label=_('Is a virtual chassis member')
     )
     )
+    config_template_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ConfigTemplate.objects.all(),
+        label=_('Config template (ID)'),
+    )
     console_ports = django_filters.BooleanFilter(
     console_ports = django_filters.BooleanFilter(
         method='_console_ports',
         method='_console_ports',
         label=_('Has console ports'),
         label=_('Has console ports'),

+ 18 - 5
netbox/dcim/forms/bulk_edit.py

@@ -6,6 +6,7 @@ from timezone_field import TimeZoneFormField
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
+from extras.models import ConfigTemplate
 from ipam.models import ASN, VLAN, VLANGroup, VRF
 from ipam.models import ASN, VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -454,6 +455,10 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
         widget=BulkEditNullBooleanSelect,
         widget=BulkEditNullBooleanSelect,
         label=_('VM role')
         label=_('VM role')
     )
     )
+    config_template = DynamicModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False
+    )
     description = forms.CharField(
     description = forms.CharField(
         max_length=200,
         max_length=200,
         required=False
         required=False
@@ -461,9 +466,9 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = DeviceRole
     model = DeviceRole
     fieldsets = (
     fieldsets = (
-        (None, ('color', 'vm_role', 'description')),
+        (None, ('color', 'vm_role', 'config_template', 'description')),
     )
     )
-    nullable_fields = ('color', 'description')
+    nullable_fields = ('color', 'config_template', 'description')
 
 
 
 
 class PlatformBulkEditForm(NetBoxModelBulkEditForm):
 class PlatformBulkEditForm(NetBoxModelBulkEditForm):
@@ -475,7 +480,10 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
         max_length=50,
         max_length=50,
         required=False
         required=False
     )
     )
-    # TODO: Bulk edit support for napalm_args
+    config_template = DynamicModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False
+    )
     description = forms.CharField(
     description = forms.CharField(
         max_length=200,
         max_length=200,
         required=False
         required=False
@@ -483,9 +491,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Platform
     model = Platform
     fieldsets = (
     fieldsets = (
-        (None, ('manufacturer', 'napalm_driver', 'description')),
+        (None, ('manufacturer', 'config_template', 'napalm_driver', 'description')),
     )
     )
-    nullable_fields = ('manufacturer', 'napalm_driver', 'description')
+    nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description')
 
 
 
 
 class DeviceBulkEditForm(NetBoxModelBulkEditForm):
 class DeviceBulkEditForm(NetBoxModelBulkEditForm):
@@ -540,6 +548,10 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
         max_length=200,
         max_length=200,
         required=False
         required=False
     )
     )
+    config_template = DynamicModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False
+    )
     comments = CommentField(
     comments = CommentField(
         widget=forms.Textarea,
         widget=forms.Textarea,
         label='Comments'
         label='Comments'
@@ -550,6 +562,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
         ('Device', ('device_role', 'status', 'tenant', 'platform', 'description')),
         ('Device', ('device_role', 'status', 'tenant', 'platform', 'description')),
         ('Location', ('site', 'location')),
         ('Location', ('site', 'location')),
         ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')),
         ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')),
+        ('Configuration', ('config_template',)),
     )
     )
     nullable_fields = (
     nullable_fields = (
         'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments',
         'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments',

+ 24 - 3
netbox/dcim/forms/bulk_import.py

@@ -8,6 +8,7 @@ from django.utils.translation import gettext as _
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
+from extras.models import ConfigTemplate
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -307,11 +308,17 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
 
 
 
 
 class DeviceRoleImportForm(NetBoxModelImportForm):
 class DeviceRoleImportForm(NetBoxModelImportForm):
+    config_template = CSVModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text=_('Config template')
+    )
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
-        fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags')
+        fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
         help_texts = {
         help_texts = {
             'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
             'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
         }
         }
@@ -325,10 +332,18 @@ class PlatformImportForm(NetBoxModelImportForm):
         to_field_name='name',
         to_field_name='name',
         help_text=_('Limit platform assignments to this manufacturer')
         help_text=_('Limit platform assignments to this manufacturer')
     )
     )
+    config_template = CSVModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text=_('Config template')
+    )
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
-        fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags')
+        fields = (
+            'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
+        )
 
 
 
 
 class BaseDeviceImportForm(NetBoxModelImportForm):
 class BaseDeviceImportForm(NetBoxModelImportForm):
@@ -434,12 +449,18 @@ class DeviceImportForm(BaseDeviceImportForm):
         required=False,
         required=False,
         help_text=_('Airflow direction')
         help_text=_('Airflow direction')
     )
     )
+    config_template = CSVModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text=_('Config template')
+    )
 
 
     class Meta(BaseDeviceImportForm.Meta):
     class Meta(BaseDeviceImportForm.Meta):
         fields = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
             'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
-            'vc_position', 'vc_priority', 'cluster', 'description', 'comments', 'tags',
+            'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags',
         ]
         ]
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):

+ 17 - 1
netbox/dcim/forms/filtersets.py

@@ -6,6 +6,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from extras.forms import LocalConfigContextFilterForm
 from extras.forms import LocalConfigContextFilterForm
+from extras.models import ConfigTemplate
 from ipam.models import ASN, L2VPN, VRF
 from ipam.models import ASN, L2VPN, VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
@@ -568,6 +569,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
 
 
 class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
 class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
     model = DeviceRole
     model = DeviceRole
+    config_template_id = DynamicModelMultipleChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False,
+        label=_('Config template')
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
@@ -578,6 +584,11 @@ class PlatformFilterForm(NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('Manufacturer')
         label=_('Manufacturer')
     )
     )
+    config_template_id = DynamicModelMultipleChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False,
+        label=_('Config template')
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
@@ -598,7 +609,7 @@ class DeviceFilterForm(
         ('Components', (
         ('Components', (
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
         )),
         )),
-        ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'local_context_data'))
+        ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data'))
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -680,6 +691,11 @@ class DeviceFilterForm(
         required=False,
         required=False,
         label='MAC address'
         label='MAC address'
     )
     )
+    config_template_id = DynamicModelMultipleChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False,
+        label=_('Config template')
+    )
     has_primary_ip = forms.NullBooleanField(
     has_primary_ip = forms.NullBooleanField(
         required=False,
         required=False,
         label='Has a primary IP',
         label='Has a primary IP',

+ 18 - 5
netbox/dcim/forms/model_forms.py

@@ -7,6 +7,7 @@ from timezone_field import TimeZoneFormField
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
+from extras.models import ConfigTemplate
 from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
 from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
@@ -416,18 +417,22 @@ class ModuleTypeForm(NetBoxModelForm):
 
 
 
 
 class DeviceRoleForm(NetBoxModelForm):
 class DeviceRoleForm(NetBoxModelForm):
+    config_template = DynamicModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False
+    )
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     fieldsets = (
         ('Device Role', (
         ('Device Role', (
-            'name', 'slug', 'color', 'vm_role', 'description', 'tags',
+            'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
         )),
         )),
     )
     )
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = [
         fields = [
-            'name', 'slug', 'color', 'vm_role', 'description', 'tags',
+            'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
         ]
         ]
 
 
 
 
@@ -436,13 +441,17 @@ class PlatformForm(NetBoxModelForm):
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False
         required=False
     )
     )
+    config_template = DynamicModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False
+    )
     slug = SlugField(
     slug = SlugField(
         max_length=64
         max_length=64
     )
     )
 
 
     fieldsets = (
     fieldsets = (
         ('Platform', (
         ('Platform', (
-            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
+            'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
 
 
         )),
         )),
     )
     )
@@ -450,7 +459,7 @@ class PlatformForm(NetBoxModelForm):
     class Meta:
     class Meta:
         model = Platform
         model = Platform
         fields = [
         fields = [
-            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
+            'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'napalm_args': forms.Textarea(),
             'napalm_args': forms.Textarea(),
@@ -565,6 +574,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         label=_('Priority'),
         label=_('Priority'),
         help_text=_("The priority of the device in the virtual chassis")
         help_text=_("The priority of the device in the virtual chassis")
     )
     )
+    config_template = DynamicModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
@@ -572,7 +585,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
             'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
             'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
             'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
             'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
             'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
             'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
-            'description', 'comments', 'tags', 'local_context_data'
+            'description', 'config_template', 'comments', 'tags', 'local_context_data'
         ]
         ]
         help_texts = {
         help_texts = {
             'device_role': _("The function this device serves"),
             'device_role': _("The function this device serves"),

+ 28 - 0
netbox/dcim/migrations/0170_configtemplate.py

@@ -0,0 +1,28 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0086_configtemplate'),
+        ('dcim', '0169_devicetype_default_platform'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='device',
+            name='config_template',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='extras.configtemplate'),
+        ),
+        migrations.AddField(
+            model_name='devicerole',
+            name='config_template',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='device_roles', to='extras.configtemplate'),
+        ),
+        migrations.AddField(
+            model_name='platform',
+            name='config_template',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='platforms', to='extras.configtemplate'),
+        ),
+    ]

+ 32 - 0
netbox/dcim/models/devices.py

@@ -410,6 +410,13 @@ class DeviceRole(OrganizationalModel):
         verbose_name='VM Role',
         verbose_name='VM Role',
         help_text=_('Virtual machines may be assigned to this role')
         help_text=_('Virtual machines may be assigned to this role')
     )
     )
+    config_template = models.ForeignKey(
+        to='extras.ConfigTemplate',
+        on_delete=models.PROTECT,
+        related_name='device_roles',
+        blank=True,
+        null=True
+    )
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('dcim:devicerole', args=[self.pk])
         return reverse('dcim:devicerole', args=[self.pk])
@@ -429,6 +436,13 @@ class Platform(OrganizationalModel):
         null=True,
         null=True,
         help_text=_('Optionally limit this platform to devices of a certain manufacturer')
         help_text=_('Optionally limit this platform to devices of a certain manufacturer')
     )
     )
+    config_template = models.ForeignKey(
+        to='extras.ConfigTemplate',
+        on_delete=models.PROTECT,
+        related_name='platforms',
+        blank=True,
+        null=True
+    )
     napalm_driver = models.CharField(
     napalm_driver = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
@@ -590,6 +604,13 @@ class Device(PrimaryModel, ConfigContextModel):
         null=True,
         null=True,
         validators=[MaxValueValidator(255)]
         validators=[MaxValueValidator(255)]
     )
     )
+    config_template = models.ForeignKey(
+        to='extras.ConfigTemplate',
+        on_delete=models.PROTECT,
+        related_name='devices',
+        blank=True,
+        null=True
+    )
 
 
     # Generic relations
     # Generic relations
     contacts = GenericRelation(
     contacts = GenericRelation(
@@ -862,6 +883,17 @@ class Device(PrimaryModel, ConfigContextModel):
     def interfaces_count(self):
     def interfaces_count(self):
         return self.vc_interfaces().count()
         return self.vc_interfaces().count()
 
 
+    def get_config_template(self):
+        """
+        Return the appropriate ConfigTemplate (if any) for this Device.
+        """
+        if self.config_template:
+            return self.config_template
+        if self.device_role.config_template:
+            return self.device_role.config_template
+        if self.platform and self.platform.config_template:
+            return self.platform.config_template
+
     def get_vc_master(self):
     def get_vc_master(self):
         """
         """
         If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
         If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.

+ 14 - 5
netbox/dcim/tables/devices.py

@@ -86,6 +86,9 @@ class DeviceRoleTable(NetBoxTable):
     )
     )
     color = columns.ColorColumn()
     color = columns.ColorColumn()
     vm_role = columns.BooleanColumn()
     vm_role = columns.BooleanColumn()
+    config_template = tables.Column(
+        linkify=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:devicerole_list'
         url_name='dcim:devicerole_list'
     )
     )
@@ -93,8 +96,8 @@ class DeviceRoleTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = models.DeviceRole
         model = models.DeviceRole
         fields = (
         fields = (
-            'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags',
-            'actions', 'created', 'last_updated',
+            'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description',
+            'slug', 'tags', 'actions', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
         default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
 
 
@@ -110,6 +113,9 @@ class PlatformTable(NetBoxTable):
     manufacturer = tables.Column(
     manufacturer = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    config_template = tables.Column(
+        linkify=True
+    )
     device_count = columns.LinkedCountColumn(
     device_count = columns.LinkedCountColumn(
         viewname='dcim:device_list',
         viewname='dcim:device_list',
         url_params={'platform_id': 'pk'},
         url_params={'platform_id': 'pk'},
@@ -127,8 +133,8 @@ class PlatformTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = models.Platform
         model = models.Platform
         fields = (
         fields = (
-            'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
-            'description', 'tags', 'actions', 'created', 'last_updated',
+            'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver',
+            'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
             'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
@@ -203,6 +209,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     vc_priority = tables.Column(
     vc_priority = tables.Column(
         verbose_name='VC Priority'
         verbose_name='VC Priority'
     )
     )
+    config_template = tables.Column(
+        linkify=True
+    )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:device_list'
         url_name='dcim:device_list'
@@ -214,7 +223,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
             'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
             'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
             'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
             'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
-            'vc_priority', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
+            'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
             'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

+ 38 - 2
netbox/dcim/views.py

@@ -1,3 +1,5 @@
+import traceback
+
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -10,10 +12,11 @@ from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from django.views.generic import View
 from django.views.generic import View
+from jinja2.exceptions import TemplateError
 
 
 from circuits.models import Circuit, CircuitTermination
 from circuits.models import Circuit, CircuitTermination
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
-from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup
+from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
 from ipam.tables import InterfaceVLANTable
 from ipam.tables import InterfaceVLANTable
 from netbox.views import generic
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
@@ -1997,6 +2000,39 @@ class DeviceInventoryView(DeviceComponentsView):
     )
     )
 
 
 
 
+@register_model_view(Device, 'render-config')
+class DeviceRenderConfigView(generic.ObjectView):
+    queryset = Device.objects.all()
+    template_name = 'dcim/device/render_config.html'
+    tab = ViewTab(
+        label=_('Render Config'),
+        permission='extras.view_configtemplate',
+        weight=2000
+    )
+
+    def get_extra_context(self, request, instance):
+        # Compile context data
+        context_data = {
+            'device': instance,
+        }
+        context_data.update(**instance.get_config_context())
+
+        # Render the config template
+        rendered_config = None
+        if config_template := instance.get_config_template():
+            try:
+                rendered_config = config_template.render(context=context_data)
+            except TemplateError as e:
+                messages.error(request, f"An error occurred while rendering the template: {e}")
+                rendered_config = traceback.format_exc()
+
+        return {
+            'config_template': config_template,
+            'context_data': context_data,
+            'rendered_config': rendered_config,
+        }
+
+
 @register_model_view(Device, 'configcontext', path='config-context')
 @register_model_view(Device, 'configcontext', path='config-context')
 class DeviceConfigContextView(ObjectConfigContextView):
 class DeviceConfigContextView(ObjectConfigContextView):
     queryset = Device.objects.annotate_config_context_data()
     queryset = Device.objects.annotate_config_context_data()
@@ -2004,7 +2040,7 @@ class DeviceConfigContextView(ObjectConfigContextView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Config Context'),
         label=_('Config Context'),
         permission='extras.view_configcontext',
         permission='extras.view_configcontext',
-        weight=2000
+        weight=2100
     )
     )
 
 
 
 

+ 9 - 0
netbox/extras/api/nested_serializers.py

@@ -7,6 +7,7 @@ from users.api.nested_serializers import NestedUserSerializer
 
 
 __all__ = [
 __all__ = [
     'NestedConfigContextSerializer',
     'NestedConfigContextSerializer',
+    'NestedConfigTemplateSerializer',
     'NestedCustomFieldSerializer',
     'NestedCustomFieldSerializer',
     'NestedCustomLinkSerializer',
     'NestedCustomLinkSerializer',
     'NestedExportTemplateSerializer',
     'NestedExportTemplateSerializer',
@@ -51,6 +52,14 @@ class NestedConfigContextSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name']
         fields = ['id', 'url', 'display', 'name']
 
 
 
 
+class NestedConfigTemplateSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail')
+
+    class Meta:
+        model = models.ConfigTemplate
+        fields = ['id', 'url', 'display', 'name']
+
+
 class NestedExportTemplateSerializer(WritableNestedSerializer):
 class NestedExportTemplateSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
 
 

+ 23 - 0
netbox/extras/api/serializers.py

@@ -16,6 +16,7 @@ from extras.utils import FeatureQuery
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
 from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
+from netbox.api.serializers.features import TaggableModelSerializer
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
@@ -29,6 +30,7 @@ from .nested_serializers import *
 
 
 __all__ = (
 __all__ = (
     'ConfigContextSerializer',
     'ConfigContextSerializer',
+    'ConfigTemplateSerializer',
     'ContentTypeSerializer',
     'ContentTypeSerializer',
     'CustomFieldSerializer',
     'CustomFieldSerializer',
     'CustomLinkSerializer',
     'CustomLinkSerializer',
@@ -383,6 +385,27 @@ class ConfigContextSerializer(ValidatedModelSerializer):
         ]
         ]
 
 
 
 
+#
+# Config templates
+#
+
+class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail')
+    data_source = NestedDataSourceSerializer(
+        required=False
+    )
+    data_file = NestedDataFileSerializer(
+        read_only=True
+    )
+
+    class Meta:
+        model = ConfigTemplate
+        fields = [
+            'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
+            'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
+        ]
+
+
 #
 #
 # Job Results
 # Job Results
 #
 #

+ 1 - 0
netbox/extras/api/urls.py

@@ -14,6 +14,7 @@ router.register('tags', views.TagViewSet)
 router.register('image-attachments', views.ImageAttachmentViewSet)
 router.register('image-attachments', views.ImageAttachmentViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
+router.register('config-templates', views.ConfigTemplateViewSet)
 router.register('reports', views.ReportViewSet, basename='report')
 router.register('reports', views.ReportViewSet, basename='report')
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('object-changes', views.ObjectChangeViewSet)
 router.register('object-changes', views.ObjectChangeViewSet)

+ 32 - 0
netbox/extras/api/views.py

@@ -5,6 +5,7 @@ from rest_framework import status
 from rest_framework.decorators import action
 from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.permissions import IsAuthenticated
+from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
 from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
@@ -19,10 +20,12 @@ from extras.scripts import get_script, get_scripts, run_script
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.features import SyncedDataMixin
 from netbox.api.features import SyncedDataMixin
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
+from netbox.api.renderers import TextRenderer
 from netbox.api.viewsets import NetBoxModelViewSet
 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 .nested_serializers import NestedConfigTemplateSerializer
 
 
 
 
 class ExtrasRootView(APIRootView):
 class ExtrasRootView(APIRootView):
@@ -157,6 +160,35 @@ class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
     filterset_class = filtersets.ConfigContextFilterSet
     filterset_class = filtersets.ConfigContextFilterSet
 
 
 
 
+#
+# Config templates
+#
+
+class ConfigTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
+    queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file')
+    serializer_class = serializers.ConfigTemplateSerializer
+    filterset_class = filtersets.ConfigTemplateFilterSet
+
+    @action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer])
+    def render(self, request, pk):
+        """
+        Render a ConfigTemplate using the context data provided (if any). If the client requests "text/plain" data,
+        return the raw rendered content, rather than serialized JSON.
+        """
+        configtemplate = self.get_object()
+        output = configtemplate.render(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
+        })
+
+
 #
 #
 # Reports
 # Reports
 #
 #

+ 31 - 2
netbox/extras/filtersets.py

@@ -4,18 +4,19 @@ from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from core.models import DataFile, DataSource
+from core.models import DataSource
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from .choices import *
 from .choices import *
+from .filters import TagFilter
 from .models import *
 from .models import *
 
 
-
 __all__ = (
 __all__ = (
     'ConfigContextFilterSet',
     'ConfigContextFilterSet',
+    'ConfigTemplateFilterSet',
     'ContentTypeFilterSet',
     'ContentTypeFilterSet',
     'CustomFieldFilterSet',
     'CustomFieldFilterSet',
     'CustomLinkFilterSet',
     'CustomLinkFilterSet',
@@ -454,6 +455,34 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         )
         )
 
 
 
 
+class ConfigTemplateFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label=_('Search'),
+    )
+    data_source_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=DataSource.objects.all(),
+        label=_('Data source (ID)'),
+    )
+    data_file_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=DataSource.objects.all(),
+        label=_('Data file (ID)'),
+    )
+    tag = TagFilter()
+
+    class Meta:
+        model = ConfigTemplate
+        fields = ['id', 'name', 'description', 'data_synced']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+
+
 #
 #
 # Filter for Local Config Context Data
 # Filter for Local Config Context Data
 #
 #

+ 14 - 0
netbox/extras/forms/bulk_edit.py

@@ -9,6 +9,7 @@ from utilities.forms import (
 
 
 __all__ = (
 __all__ = (
     'ConfigContextBulkEditForm',
     'ConfigContextBulkEditForm',
+    'ConfigTemplateBulkEditForm',
     'CustomFieldBulkEditForm',
     'CustomFieldBulkEditForm',
     'CustomLinkBulkEditForm',
     'CustomLinkBulkEditForm',
     'ExportTemplateBulkEditForm',
     'ExportTemplateBulkEditForm',
@@ -201,6 +202,19 @@ class ConfigContextBulkEditForm(BulkEditForm):
     nullable_fields = ('description',)
     nullable_fields = ('description',)
 
 
 
 
+class ConfigTemplateBulkEditForm(BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    nullable_fields = ('description',)
+
+
 class JournalEntryBulkEditForm(BulkEditForm):
 class JournalEntryBulkEditForm(BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=JournalEntry.objects.all(),
         queryset=JournalEntry.objects.all(),

+ 10 - 0
netbox/extras/forms/bulk_import.py

@@ -10,6 +10,7 @@ from extras.utils import FeatureQuery
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
 
 
 __all__ = (
 __all__ = (
+    'ConfigTemplateImportForm',
     'CustomFieldImportForm',
     'CustomFieldImportForm',
     'CustomLinkImportForm',
     'CustomLinkImportForm',
     'ExportTemplateImportForm',
     'ExportTemplateImportForm',
@@ -83,6 +84,15 @@ class ExportTemplateImportForm(CSVModelForm):
         )
         )
 
 
 
 
+class ConfigTemplateImportForm(CSVModelForm):
+
+    class Meta:
+        model = ConfigTemplate
+        fields = (
+            'name', 'description', 'environment_params', 'template_code', 'tags',
+        )
+
+
 class SavedFilterImportForm(CSVModelForm):
 class SavedFilterImportForm(CSVModelForm):
     content_types = CSVMultipleContentTypeField(
     content_types = CSVMultipleContentTypeField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),

+ 22 - 0
netbox/extras/forms/filtersets.py

@@ -20,6 +20,7 @@ from .mixins import SavedFiltersMixin
 
 
 __all__ = (
 __all__ = (
     'ConfigContextFilterForm',
     'ConfigContextFilterForm',
+    'ConfigTemplateFilterForm',
     'CustomFieldFilterForm',
     'CustomFieldFilterForm',
     'CustomLinkFilterForm',
     'CustomLinkFilterForm',
     'ExportTemplateFilterForm',
     'ExportTemplateFilterForm',
@@ -358,6 +359,27 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
     )
     )
 
 
 
 
+class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        ('Data', ('data_source_id', 'data_file_id')),
+    )
+    data_source_id = DynamicModelMultipleChoiceField(
+        queryset=DataSource.objects.all(),
+        required=False,
+        label=_('Data source')
+    )
+    data_file_id = DynamicModelMultipleChoiceField(
+        queryset=DataFile.objects.all(),
+        required=False,
+        label=_('Data file'),
+        query_params={
+            'source_id': '$data_source_id'
+        }
+    )
+    tag = TagFilterField(ConfigTemplate)
+
+
 class LocalConfigContextFilterForm(forms.Form):
 class LocalConfigContextFilterForm(forms.Form):
     local_context_data = forms.NullBooleanField(
     local_context_data = forms.NullBooleanField(
         required=False,
         required=False,

+ 29 - 0
netbox/extras/forms/model_forms.py

@@ -18,6 +18,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 __all__ = (
 __all__ = (
     'ConfigContextForm',
     'ConfigContextForm',
+    'ConfigTemplateForm',
     'CustomFieldForm',
     'CustomFieldForm',
     'CustomLinkForm',
     'CustomLinkForm',
     'ExportTemplateForm',
     'ExportTemplateForm',
@@ -269,6 +270,34 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
         return self.cleaned_data
         return self.cleaned_data
 
 
 
 
+class ConfigTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+    template_code = forms.CharField(
+        required=False,
+        widget=forms.Textarea(attrs={'class': 'font-monospace'})
+    )
+
+    fieldsets = (
+        ('Config Template', ('name', 'description', 'environment_params', 'tags')),
+        ('Content', ('data_source', 'data_file', 'template_code',)),
+    )
+
+    class Meta:
+        model = ConfigTemplate
+        fields = '__all__'
+
+    def clean(self):
+        super().clean()
+
+        if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
+            raise forms.ValidationError("Must specify either local content or a data file")
+
+        return self.cleaned_data
+
+
 class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
 class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:

+ 3 - 0
netbox/extras/graphql/schema.py

@@ -8,6 +8,9 @@ class ExtrasQuery(graphene.ObjectType):
     config_context = ObjectField(ConfigContextType)
     config_context = ObjectField(ConfigContextType)
     config_context_list = ObjectListField(ConfigContextType)
     config_context_list = ObjectListField(ConfigContextType)
 
 
+    config_template = ObjectField(ConfigTemplateType)
+    config_template_list = ObjectListField(ConfigTemplateType)
+
     custom_field = ObjectField(CustomFieldType)
     custom_field = ObjectField(CustomFieldType)
     custom_field_list = ObjectListField(CustomFieldType)
     custom_field_list = ObjectListField(CustomFieldType)
 
 

+ 9 - 0
netbox/extras/graphql/types.py

@@ -4,6 +4,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType
 
 
 __all__ = (
 __all__ = (
     'ConfigContextType',
     'ConfigContextType',
+    'ConfigTemplateType',
     'CustomFieldType',
     'CustomFieldType',
     'CustomLinkType',
     'CustomLinkType',
     'ExportTemplateType',
     'ExportTemplateType',
@@ -24,6 +25,14 @@ class ConfigContextType(ObjectType):
         filterset_class = filtersets.ConfigContextFilterSet
         filterset_class = filtersets.ConfigContextFilterSet
 
 
 
 
+class ConfigTemplateType(ObjectType):
+
+    class Meta:
+        model = models.ConfigTemplate
+        fields = '__all__'
+        filterset_class = filtersets.ConfigTemplateFilterSet
+
+
 class CustomFieldType(ObjectType):
 class CustomFieldType(ObjectType):
 
 
     class Meta:
     class Meta:

+ 34 - 0
netbox/extras/migrations/0086_configtemplate.py

@@ -0,0 +1,34 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0001_initial'),
+        ('extras', '0085_synced_data'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ConfigTemplate',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('data_path', models.CharField(blank=True, editable=False, max_length=1000)),
+                ('data_synced', models.DateTimeField(blank=True, editable=False, null=True)),
+                ('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)),
+                ('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')),
+            ],
+            options={
+                'ordering': ('name',),
+            },
+        ),
+    ]

+ 2 - 1
netbox/extras/models/__init__.py

@@ -1,5 +1,5 @@
 from .change_logging import ObjectChange
 from .change_logging import ObjectChange
-from .configcontexts import ConfigContext, ConfigContextModel
+from .configs import *
 from .customfields import CustomField
 from .customfields import CustomField
 from .models import *
 from .models import *
 from .search import *
 from .search import *
@@ -12,6 +12,7 @@ __all__ = (
     'ConfigContext',
     'ConfigContext',
     'ConfigContextModel',
     'ConfigContextModel',
     'ConfigRevision',
     'ConfigRevision',
+    'ConfigTemplate',
     'CustomField',
     'CustomField',
     'CustomLink',
     'CustomLink',
     'ExportTemplate',
     'ExportTemplate',

+ 81 - 1
netbox/extras/models/configcontexts.py → netbox/extras/models/configs.py

@@ -3,15 +3,21 @@ from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
+from django.utils.translation import gettext as _
+from jinja2.loaders import BaseLoader
+from jinja2.sandbox import SandboxedEnvironment
 
 
 from extras.querysets import ConfigContextQuerySet
 from extras.querysets import ConfigContextQuerySet
+from netbox.config import get_config
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
-from netbox.models.features import SyncedDataMixin
+from netbox.models.features import ExportTemplatesMixin, SyncedDataMixin, TagsMixin
+from utilities.jinja2 import ConfigTemplateLoader
 from utilities.utils import deepmerge
 from utilities.utils import deepmerge
 
 
 __all__ = (
 __all__ = (
     'ConfigContext',
     'ConfigContext',
     'ConfigContextModel',
     'ConfigContextModel',
+    'ConfigTemplate',
 )
 )
 
 
 
 
@@ -182,3 +188,77 @@ class ConfigContextModel(models.Model):
             raise ValidationError(
             raise ValidationError(
                 {'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'}
                 {'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'}
             )
             )
+
+
+#
+# Config templates
+#
+
+class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
+    name = models.CharField(
+        max_length=100
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+    template_code = models.TextField(
+        help_text=_('Jinja2 template code.')
+    )
+    environment_params = models.JSONField(
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        ordering = ('name',)
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('extras:configtemplate', args=[self.pk])
+
+    def sync_data(self):
+        """
+        Synchronize template content from the designated DataFile (if any).
+        """
+        self.template_code = self.data_file.data_as_string
+        self.data_synced = timezone.now()
+
+    def render(self, context=None):
+        """
+        Render the contents of the template.
+        """
+        context = context or {}
+
+        # 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)
+
+        output = template.render(**context)
+
+        # Replace CRLF-style line terminators
+        return output.replace('\r\n', '\n')
+
+    def _get_environment(self):
+        """
+        Instantiate and return a Jinja2 environment suitable for rendering the ConfigTemplate.
+        """
+        # Initialize the template loader & cache the base template code (if applicable)
+        if self.data_file:
+            loader = ConfigTemplateLoader(data_source=self.data_source)
+            loader.cache_templates({
+                self.data_file.path: self.template_code
+            })
+        else:
+            loader = BaseLoader()
+
+        # Initialize the environment
+        environment = SandboxedEnvironment(loader=loader)
+        environment.filters.update(get_config().JINJA2_FILTERS)
+
+        return environment

+ 29 - 0
netbox/extras/tables/tables.py

@@ -8,6 +8,7 @@ from .template_code import *
 
 
 __all__ = (
 __all__ = (
     'ConfigContextTable',
     'ConfigContextTable',
+    'ConfigTemplateTable',
     'CustomFieldTable',
     'CustomFieldTable',
     'CustomLinkTable',
     'CustomLinkTable',
     'ExportTemplateTable',
     'ExportTemplateTable',
@@ -223,6 +224,34 @@ class ConfigContextTable(NetBoxTable):
         default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
         default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
 
 
 
 
+class ConfigTemplateTable(NetBoxTable):
+    name = tables.Column(
+        linkify=True
+    )
+    data_source = tables.Column(
+        linkify=True
+    )
+    data_file = tables.Column(
+        linkify=True
+    )
+    is_synced = columns.BooleanColumn(
+        verbose_name='Synced'
+    )
+    tags = columns.TagColumn(
+        url_name='extras:configtemplate_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = ConfigTemplate
+        fields = (
+            'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
+            'tags',
+        )
+        default_columns = (
+            'pk', 'name', 'description', 'is_synced',
+        )
+
+
 class ObjectChangeTable(NetBoxTable):
 class ObjectChangeTable(NetBoxTable):
     time = tables.DateTimeColumn(
     time = tables.DateTimeColumn(
         linkify=True,
         linkify=True,

+ 8 - 0
netbox/extras/urls.py

@@ -64,6 +64,14 @@ urlpatterns = [
     path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'),
     path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'),
     path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
     path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
 
 
+    # Config templates
+    path('config-templates/', views.ConfigTemplateListView.as_view(), name='configtemplate_list'),
+    path('config-templates/add/', views.ConfigTemplateEditView.as_view(), name='configtemplate_add'),
+    path('config-templates/edit/', views.ConfigTemplateBulkEditView.as_view(), name='configtemplate_bulk_edit'),
+    path('config-templates/delete/', views.ConfigTemplateBulkDeleteView.as_view(), name='configtemplate_bulk_delete'),
+    path('config-templates/sync/', views.ConfigTemplateBulkSyncDataView.as_view(), name='configtemplate_bulk_sync'),
+    path('config-templates/<int:pk>/', include(get_model_urls('extras', 'configtemplate'))),
+
     # Image attachments
     # Image attachments
     path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
     path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
     path('image-attachments/<int:pk>/', include(get_model_urls('extras', 'imageattachment'))),
     path('image-attachments/<int:pk>/', include(get_model_urls('extras', 'imageattachment'))),

+ 52 - 0
netbox/extras/views.py

@@ -452,6 +452,58 @@ class ObjectConfigContextView(generic.ObjectView):
         }
         }
 
 
 
 
+#
+# Config templates
+#
+
+class ConfigTemplateListView(generic.ObjectListView):
+    queryset = ConfigTemplate.objects.all()
+    filterset = filtersets.ConfigTemplateFilterSet
+    filterset_form = forms.ConfigTemplateFilterForm
+    table = tables.ConfigTemplateTable
+    template_name = 'extras/configtemplate_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync')
+
+
+@register_model_view(ConfigTemplate)
+class ConfigTemplateView(generic.ObjectView):
+    queryset = ConfigTemplate.objects.all()
+
+
+@register_model_view(ConfigTemplate, 'edit')
+class ConfigTemplateEditView(generic.ObjectEditView):
+    queryset = ConfigTemplate.objects.all()
+    form = forms.ConfigTemplateForm
+
+
+@register_model_view(ConfigTemplate, 'delete')
+class ConfigTemplateDeleteView(generic.ObjectDeleteView):
+    queryset = ConfigTemplate.objects.all()
+
+
+class ConfigTemplateBulkImportView(generic.BulkImportView):
+    queryset = ConfigTemplate.objects.all()
+    model_form = forms.ConfigTemplateImportForm
+    table = tables.ConfigTemplateTable
+
+
+class ConfigTemplateBulkEditView(generic.BulkEditView):
+    queryset = ConfigTemplate.objects.all()
+    filterset = filtersets.ConfigTemplateFilterSet
+    table = tables.ConfigTemplateTable
+    form = forms.ConfigTemplateBulkEditForm
+
+
+class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
+    queryset = ConfigTemplate.objects.all()
+    filterset = filtersets.ConfigTemplateFilterSet
+    table = tables.ConfigTemplateTable
+
+
+class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
+    queryset = ConfigTemplate.objects.all()
+
+
 #
 #
 # Change logging
 # Change logging
 #
 #

+ 17 - 1
netbox/netbox/api/renderers.py

@@ -1,4 +1,9 @@
-from rest_framework.renderers import BrowsableAPIRenderer
+from rest_framework.renderers import BaseRenderer, BrowsableAPIRenderer
+
+__all__ = (
+    'FormlessBrowsableAPIRenderer',
+    'TextRenderer',
+)
 
 
 
 
 class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
 class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
@@ -10,3 +15,14 @@ class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
 
 
     def get_filter_form(self, data, view, request):
     def get_filter_form(self, data, view, request):
         return None
         return None
+
+
+class TextRenderer(BaseRenderer):
+    """
+    Return raw data as plain text.
+    """
+    media_type = 'text/plain'
+    format = 'txt'
+
+    def render(self, data, accepted_media_type=None, renderer_context=None):
+        return str(data)

+ 1 - 0
netbox/netbox/navigation/menu.py

@@ -311,6 +311,7 @@ OTHER_MENU = Menu(
             items=(
             items=(
                 get_model_item('extras', 'tag', 'Tags'),
                 get_model_item('extras', 'tag', 'Tags'),
                 get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']),
                 get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']),
+                get_model_item('extras', 'configtemplate', _('Config Templates'), actions=['add']),
             ),
             ),
         ),
         ),
     ),
     ),

+ 5 - 3
netbox/templates/dcim/device.html

@@ -9,9 +9,7 @@
     <div class="row">
     <div class="row">
         <div class="col col-12 col-xl-6">
         <div class="col col-12 col-xl-6">
             <div class="card">
             <div class="card">
-                <h5 class="card-header">
-                    Device
-                </h5>
+                <h5 class="card-header">Device</h5>
                 <div class="card-body">
                 <div class="card-body">
                     <table class="table table-hover attr-table">
                     <table class="table table-hover attr-table">
                         <tr>
                         <tr>
@@ -111,6 +109,10 @@
                             <th scope="row">Asset Tag</th>
                             <th scope="row">Asset Tag</th>
                             <td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
                             <td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
                         </tr>
                         </tr>
+                        <tr>
+                            <th scope="row">Config Template</th>
+                            <td>{{ object.config_template|linkify|placeholder }}</td>
+                        </tr>
                     </table>
                     </table>
                 </div>
                 </div>
             </div>
             </div>

+ 47 - 0
netbox/templates/dcim/device/render_config.html

@@ -0,0 +1,47 @@
+{% extends 'dcim/device/base.html' %}
+{% load static %}
+
+{% block title %}{{ object }} - Config{% endblock %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col-5">
+      <div class="card">
+        <h5 class="card-header">Config Template</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Config Template</th>
+              <td>{{ config_template|linkify|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Data Source</th>
+              <td>{{ config_template.data_file.source|linkify|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Data File</th>
+              <td>{{ config_template.data_file|linkify|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+    </div>
+    <div class="col-7">
+      <div class="card">
+        <h5 class="card-header">Context Data</h5>
+        <pre class="card-body">{{ context_data|pprint }}</pre>
+      </div>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col">
+      <div class="card">
+        {% if config_template %}
+          <pre class="card-body">{{ rendered_config }}</pre>
+        {% else %}
+          <div class="card-body text-muted">No configuration template found</div>
+        {% endif %}
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 1 - 0
netbox/templates/dcim/device_edit.html

@@ -65,6 +65,7 @@
       </div>
       </div>
       {% render_field form.status %}
       {% render_field form.status %}
       {% render_field form.platform %}
       {% render_field form.platform %}
+      {% render_field form.config_template %}
       {% if object.pk %}
       {% if object.pk %}
         {% render_field form.primary_ip4 %}
         {% render_field form.primary_ip4 %}
         {% render_field form.primary_ip6 %}
         {% render_field form.primary_ip6 %}

+ 4 - 0
netbox/templates/dcim/devicerole.html

@@ -42,6 +42,10 @@
             <th scope="row">VM Role</th>
             <th scope="row">VM Role</th>
             <td>{% checkmark object.vm_role %}</td>
             <td>{% checkmark object.vm_role %}</td>
           </tr>
           </tr>
+          <tr>
+            <th scope="row">Config Template</th>
+            <td>{{ object.config_template|linkify|placeholder }}</td>
+          </tr>
         </table>
         </table>
       </div>
       </div>
     </div>
     </div>

+ 4 - 0
netbox/templates/dcim/platform.html

@@ -39,6 +39,10 @@
             <th scope="row">Manufacturer</th>
             <th scope="row">Manufacturer</th>
             <td>{{ object.manufacturer|linkify|placeholder }}</td>
             <td>{{ object.manufacturer|linkify|placeholder }}</td>
           </tr>
           </tr>
+          <tr>
+            <th scope="row">Config Template</th>
+            <td>{{ object.config_template|linkify|placeholder }}</td>
+          </tr>
           <tr>
           <tr>
             <th scope="row">NAPALM Driver</th>
             <th scope="row">NAPALM Driver</th>
             <td>{{ object.napalm_driver|placeholder }}</td>
             <td>{{ object.napalm_driver|placeholder }}</td>

+ 77 - 0
netbox/templates/extras/configtemplate.html

@@ -0,0 +1,77 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-5">
+      <div class="card">
+        <h5 class="card-header">Config Template</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Name</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Description</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Data Source</th>
+              <td>
+                {% if object.data_source %}
+                  <a href="{{ object.data_source.get_absolute_url }}">{{ object.data_source }}</a>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Data File</th>
+              <td>
+                {% if object.data_file %}
+                  <a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>
+                {% elif object.data_path %}
+                  <div class="float-end text-warning">
+                    <i class="mdi mdi-alert" title="The data file associated with this object has been deleted."></i>
+                  </div>
+                  {{ object.data_path }}
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Data Synced</th>
+              <td>{{ object.data_synced|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-7">
+      <div class="card">
+        <h5 class="card-header">Environment Parameters</h5>
+        <div class="card-body">
+          <pre>{{ object.environment_params }}</pre>
+        </div>
+      </div>
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      <div class="card">
+        <h5 class="card-header">Template</h5>
+        <div class="card-body">
+          {% include 'inc/sync_warning.html' %}
+          <pre>{{ object.template_code }}</pre>
+        </div>
+      </div>
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 10 - 0
netbox/templates/extras/configtemplate_list.html

@@ -0,0 +1,10 @@
+{% extends 'generic/object_list.html' %}
+
+{% block bulk_buttons %}
+  {% if perms.extras.sync_configtemplate %}
+    <button type="submit" name="_sync" formaction="{% url 'extras:configtemplate_bulk_sync' %}" class="btn btn-primary btn-sm">
+      <i class="mdi mdi-sync" aria-hidden="true"></i> Sync Data
+    </button>
+  {% endif %}
+  {{ block.super }}
+{% endblock %}

+ 37 - 0
netbox/utilities/jinja2.py

@@ -0,0 +1,37 @@
+from django.apps import apps
+from jinja2 import BaseLoader, TemplateNotFound
+from jinja2.meta import find_referenced_templates
+
+__all__ = (
+    'ConfigTemplateLoader',
+)
+
+
+class ConfigTemplateLoader(BaseLoader):
+    """
+    Custom Jinja2 loader to facilitate populating template content from DataFiles.
+    """
+    def __init__(self, data_source):
+        self.data_source = data_source
+        self._template_cache = {}
+
+    def get_source(self, environment, template):
+        DataFile = apps.get_model('core', 'DataFile')
+
+        # Retrieve template content from cache
+        try:
+            template_source = self._template_cache[template]
+        except KeyError:
+            raise TemplateNotFound(template)
+
+        # Find and pre-fetch referenced templates
+        if referenced_templates := find_referenced_templates(environment.parse(template_source)):
+            self.cache_templates({
+                df.path: df.data_as_string for df in
+                DataFile.objects.filter(source=self.data_source, path__in=referenced_templates)
+            })
+
+        return template_source, template, lambda: True
+
+    def cache_templates(self, templates):
+        self._template_cache.update(templates)