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
This commit is contained in:
Jeremy Stretch 2023-02-17 08:33:08 -05:00 committed by jeremystretch
parent db4e00d394
commit 73a7a2d27a
45 changed files with 886 additions and 36 deletions

View File

@ -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.

View File

@ -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:
| Type | Devices | Virtual Machines |

View File

@ -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.
### 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
Each device may designate one primary IPv4 address and/or one primary IPv6 address for management purposes.

View File

@ -19,3 +19,7 @@ The color used when displaying the role in the NetBox UI.
### VM Role
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.

View File

@ -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.
### Configuration Template
The default [configuration template](../extras/configtemplate.md) for devices assigned to this platform.
### NAPALM Driver
The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform.

View File

@ -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.

View File

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

View File

@ -9,6 +9,7 @@ from timezone_field.rest_framework import TimeZoneSerializerField
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.api.nested_serializers import NestedConfigTemplateSerializer
from ipam.api.nested_serializers import (
NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
NestedVRFSerializer,
@ -605,8 +606,8 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
class Meta:
model = DeviceRole
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:
model = Platform
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)
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)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
class Meta:
model = Device
@ -658,7 +660,7 @@ class DeviceSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'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)

View File

@ -366,7 +366,7 @@ class InventoryItemTemplateViewSet(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'),
virtualmachine_count=count_related(VirtualMachine, 'role')
)
@ -379,7 +379,7 @@ class DeviceRoleViewSet(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'),
virtualmachine_count=count_related(VirtualMachine, 'platform')
)

View File

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

View File

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

View File

@ -8,6 +8,7 @@ from django.utils.translation import gettext as _
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import VRF
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
@ -307,11 +308,17 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class DeviceRoleImportForm(NetBoxModelImportForm):
config_template = CSVModelChoiceField(
queryset=ConfigTemplate.objects.all(),
to_field_name='name',
required=False,
help_text=_('Config template')
)
slug = SlugField()
class Meta:
model = DeviceRole
fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags')
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
}
@ -325,10 +332,18 @@ class PlatformImportForm(NetBoxModelImportForm):
to_field_name='name',
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:
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):
@ -434,12 +449,18 @@ class DeviceImportForm(BaseDeviceImportForm):
required=False,
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):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'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):

View File

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

View File

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

View File

@ -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'),
),
]

View File

@ -410,6 +410,13 @@ class DeviceRole(OrganizationalModel):
verbose_name='VM 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):
return reverse('dcim:devicerole', args=[self.pk])
@ -429,6 +436,13 @@ class Platform(OrganizationalModel):
null=True,
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(
max_length=50,
blank=True,
@ -590,6 +604,13 @@ class Device(PrimaryModel, ConfigContextModel):
null=True,
validators=[MaxValueValidator(255)]
)
config_template = models.ForeignKey(
to='extras.ConfigTemplate',
on_delete=models.PROTECT,
related_name='devices',
blank=True,
null=True
)
# Generic relations
contacts = GenericRelation(
@ -862,6 +883,17 @@ class Device(PrimaryModel, ConfigContextModel):
def interfaces_count(self):
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):
"""
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.

View File

@ -86,6 +86,9 @@ class DeviceRoleTable(NetBoxTable):
)
color = columns.ColorColumn()
vm_role = columns.BooleanColumn()
config_template = tables.Column(
linkify=True
)
tags = columns.TagColumn(
url_name='dcim:devicerole_list'
)
@ -93,8 +96,8 @@ class DeviceRoleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.DeviceRole
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')
@ -110,6 +113,9 @@ class PlatformTable(NetBoxTable):
manufacturer = tables.Column(
linkify=True
)
config_template = tables.Column(
linkify=True
)
device_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'platform_id': 'pk'},
@ -127,8 +133,8 @@ class PlatformTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.Platform
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 = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
@ -203,6 +209,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
vc_priority = tables.Column(
verbose_name='VC Priority'
)
config_template = tables.Column(
linkify=True
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
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',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
'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 = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@ -1,3 +1,5 @@
import traceback
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
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.translation import gettext as _
from django.views.generic import View
from jinja2.exceptions import TemplateError
from circuits.models import Circuit, CircuitTermination
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 netbox.views import generic
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')
class DeviceConfigContextView(ObjectConfigContextView):
queryset = Device.objects.annotate_config_context_data()
@ -2004,7 +2040,7 @@ class DeviceConfigContextView(ObjectConfigContextView):
tab = ViewTab(
label=_('Config Context'),
permission='extras.view_configcontext',
weight=2000
weight=2100
)

View File

@ -7,6 +7,7 @@ from users.api.nested_serializers import NestedUserSerializer
__all__ = [
'NestedConfigContextSerializer',
'NestedConfigTemplateSerializer',
'NestedCustomFieldSerializer',
'NestedCustomLinkSerializer',
'NestedExportTemplateSerializer',
@ -51,6 +52,14 @@ class NestedConfigContextSerializer(WritableNestedSerializer):
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):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')

View File

@ -16,6 +16,7 @@ from extras.utils import FeatureQuery
from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
from netbox.api.serializers.features import TaggableModelSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
@ -29,6 +30,7 @@ from .nested_serializers import *
__all__ = (
'ConfigContextSerializer',
'ConfigTemplateSerializer',
'ContentTypeSerializer',
'CustomFieldSerializer',
'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
#

View File

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

View File

@ -5,6 +5,7 @@ from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
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.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
from utilities.utils import copy_safe_request, count_related
from . import serializers
from .nested_serializers import NestedConfigTemplateSerializer
class ExtrasRootView(APIRootView):
@ -157,6 +160,35 @@ class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
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
#

View File

@ -4,18 +4,19 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
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 netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from tenancy.models import Tenant, TenantGroup
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from virtualization.models import Cluster, ClusterGroup, ClusterType
from .choices import *
from .filters import TagFilter
from .models import *
__all__ = (
'ConfigContextFilterSet',
'ConfigTemplateFilterSet',
'ContentTypeFilterSet',
'CustomFieldFilterSet',
'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
#

View File

@ -9,6 +9,7 @@ from utilities.forms import (
__all__ = (
'ConfigContextBulkEditForm',
'ConfigTemplateBulkEditForm',
'CustomFieldBulkEditForm',
'CustomLinkBulkEditForm',
'ExportTemplateBulkEditForm',
@ -201,6 +202,19 @@ class ConfigContextBulkEditForm(BulkEditForm):
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):
pk = forms.ModelMultipleChoiceField(
queryset=JournalEntry.objects.all(),

View File

@ -10,6 +10,7 @@ from extras.utils import FeatureQuery
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
__all__ = (
'ConfigTemplateImportForm',
'CustomFieldImportForm',
'CustomLinkImportForm',
'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):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),

View File

@ -20,6 +20,7 @@ from .mixins import SavedFiltersMixin
__all__ = (
'ConfigContextFilterForm',
'ConfigTemplateFilterForm',
'CustomFieldFilterForm',
'CustomLinkFilterForm',
'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):
local_context_data = forms.NullBooleanField(
required=False,

View File

@ -18,6 +18,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
'ConfigContextForm',
'ConfigTemplateForm',
'CustomFieldForm',
'CustomLinkForm',
'ExportTemplateForm',
@ -269,6 +270,34 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
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 Meta:

View File

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

View File

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

View File

@ -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',),
},
),
]

View File

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

View File

@ -3,15 +3,21 @@ from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
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 netbox.config import get_config
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
__all__ = (
'ConfigContext',
'ConfigContextModel',
'ConfigTemplate',
)
@ -182,3 +188,77 @@ class ConfigContextModel(models.Model):
raise ValidationError(
{'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

View File

@ -8,6 +8,7 @@ from .template_code import *
__all__ = (
'ConfigContextTable',
'ConfigTemplateTable',
'CustomFieldTable',
'CustomLinkTable',
'ExportTemplateTable',
@ -223,6 +224,34 @@ class ConfigContextTable(NetBoxTable):
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):
time = tables.DateTimeColumn(
linkify=True,

View File

@ -64,6 +64,14 @@ urlpatterns = [
path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'),
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
path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
path('image-attachments/<int:pk>/', include(get_model_urls('extras', 'imageattachment'))),

View File

@ -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
#

View File

@ -1,4 +1,9 @@
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.renderers import BaseRenderer, BrowsableAPIRenderer
__all__ = (
'FormlessBrowsableAPIRenderer',
'TextRenderer',
)
class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
@ -10,3 +15,14 @@ class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
def get_filter_form(self, data, view, request):
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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