diff --git a/netbox/dcim/migrations/0170_configtemplate.py b/netbox/dcim/migrations/0170_configtemplate.py
index b1aac0ad2..f9508424d 100644
--- a/netbox/dcim/migrations/0170_configtemplate.py
+++ b/netbox/dcim/migrations/0170_configtemplate.py
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
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'),
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'),
),
migrations.AddField(
model_name='devicerole',
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 6b8e92743..857251caf 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -24,7 +24,7 @@ from utilities.choices import ColorChoices
from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
from utilities.tracking import TrackingModelMixin
from .device_components import *
-from .mixins import WeightMixin
+from .mixins import RenderConfigMixin, WeightMixin
__all__ = (
@@ -525,7 +525,14 @@ def update_interface_bridges(device, interface_templates, module=None):
interface.save()
-class Device(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, ConfigContextModel, TrackingModelMixin):
+class Device(
+ ContactsMixin,
+ ImageAttachmentsMixin,
+ RenderConfigMixin,
+ ConfigContextModel,
+ TrackingModelMixin,
+ PrimaryModel
+):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
@@ -686,13 +693,6 @@ class Device(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, ConfigContextMo
validators=[MaxValueValidator(255)],
help_text=_('Virtual chassis master election priority')
)
- config_template = models.ForeignKey(
- to='extras.ConfigTemplate',
- on_delete=models.PROTECT,
- related_name='devices',
- blank=True,
- null=True
- )
latitude = models.DecimalField(
verbose_name=_('latitude'),
max_digits=8,
@@ -1070,17 +1070,6 @@ class Device(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, ConfigContextMo
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.role.config_template:
- return self.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.
diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py
index f787c8e97..95f6d41fe 100644
--- a/netbox/dcim/models/mixins.py
+++ b/netbox/dcim/models/mixins.py
@@ -4,6 +4,11 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from utilities.utils import to_grams
+__all__ = (
+ 'RenderConfigMixin',
+ 'WeightMixin',
+)
+
class WeightMixin(models.Model):
weight = models.DecimalField(
@@ -44,3 +49,27 @@ class WeightMixin(models.Model):
# Validate weight and weight_unit
if self.weight and not self.weight_unit:
raise ValidationError(_("Must specify a unit when setting a weight"))
+
+
+class RenderConfigMixin(models.Model):
+ config_template = models.ForeignKey(
+ to='extras.ConfigTemplate',
+ on_delete=models.PROTECT,
+ related_name='%(class)ss',
+ blank=True,
+ null=True
+ )
+
+ class Meta:
+ abstract = True
+
+ def get_config_template(self):
+ """
+ Return the appropriate ConfigTemplate (if any) for this Device.
+ """
+ if self.config_template:
+ return self.config_template
+ if self.role.config_template:
+ return self.role.config_template
+ if self.platform and self.platform.config_template:
+ return self.platform.config_template
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index 04e038b92..27f5ea114 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -43,6 +43,10 @@
{{ object.tenant|linkify|placeholder }}
+
{% trans "Primary IPv4" %} |
diff --git a/netbox/templates/virtualization/virtualmachine/render_config.html b/netbox/templates/virtualization/virtualmachine/render_config.html
new file mode 100644
index 000000000..7b638199b
--- /dev/null
+++ b/netbox/templates/virtualization/virtualmachine/render_config.html
@@ -0,0 +1,70 @@
+{% extends 'virtualization/virtualmachine/base.html' %}
+{% load static %}
+{% load i18n %}
+
+{% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% trans "Config Template" %} |
+ {{ config_template|linkify|placeholder }} |
+
+
+ {% trans "Data Source" %} |
+ {{ config_template.data_file.source|linkify|placeholder }} |
+
+
+ {% trans "Data File" %} |
+ {{ config_template.data_file|linkify|placeholder }} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ context_data|pprint }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if config_template %}
+ {{ rendered_config }}
+ {% else %}
+ {% trans "No configuration template found" %}
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index 693bb362f..c9fa559aa 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -5,6 +5,7 @@ from dcim.api.nested_serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
)
from dcim.choices import InterfaceModeChoices
+from extras.api.nested_serializers import NestedConfigTemplateSerializer
from ipam.api.nested_serializers import (
NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer,
)
@@ -79,6 +80,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
+ config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
# Counter fields
interface_count = serializers.IntegerField(read_only=True)
@@ -88,7 +90,8 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
fields = [
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
- 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'interface_count',
+ 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'interface_count',
]
validators = []
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index cf716ca32..571dbe64b 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -5,6 +5,7 @@ from django.utils.translation import gettext as _
from dcim.filtersets import CommonInterfaceFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
+from extras.models import ConfigTemplate
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@@ -228,6 +229,10 @@ class VirtualMachineFilterSet(
method='_has_primary_ip',
label=_('Has a primary IP'),
)
+ config_template_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=ConfigTemplate.objects.all(),
+ label=_('Config template (ID)'),
+ )
class Meta:
model = VirtualMachine
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index cc281a4f7..a33ffac53 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/netbox/virtualization/forms/bulk_edit.py
@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
+from extras.models import ConfigTemplate
from ipam.models import VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
@@ -174,12 +175,17 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
+ config_template = DynamicModelChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ required=False
+ )
comments = CommentField()
model = VirtualMachine
fieldsets = (
(None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description')),
- (_('Resources'), ('vcpus', 'memory', 'disk'))
+ (_('Resources'), ('vcpus', 'memory', 'disk')),
+ ('Configuration', ('config_template',)),
)
nullable_fields = (
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments',
diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py
index 19f718f03..04fe2d7ae 100644
--- a/netbox/virtualization/forms/bulk_import.py
+++ b/netbox/virtualization/forms/bulk_import.py
@@ -2,6 +2,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import InterfaceModeChoices
from dcim.models import Device, DeviceRole, Platform, Site
+from extras.models import ConfigTemplate
from ipam.models import VRF
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
@@ -123,12 +124,19 @@ class VirtualMachineImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text=_('Assigned platform')
)
+ config_template = CSVModelChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ to_field_name='name',
+ required=False,
+ label=_('Config template'),
+ help_text=_('Config template')
+ )
class Meta:
model = VirtualMachine
fields = (
'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
- 'description', 'comments', 'tags',
+ 'description', 'config_template', 'comments', 'tags',
)
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index cd1269645..99ac0cb77 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.forms import LocalConfigContextFilterForm
+from extras.models import ConfigTemplate
from ipam.models import L2VPN, VRF
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
@@ -93,7 +94,7 @@ class VirtualMachineFilterForm(
(None, ('q', 'filter_id', 'tag')),
(_('Cluster'), ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
(_('Location'), ('region_id', 'site_group_id', 'site_id')),
- (_('Attributes'), ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
+ (_('Attributes'), ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'config_template_id', 'local_context_data')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
@@ -170,6 +171,11 @@ class VirtualMachineFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
+ config_template_id = DynamicModelMultipleChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ required=False,
+ label=_('Config template')
+ )
tag = TagFilterField(model)
diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py
index 0c8c98f9f..21dbc895a 100644
--- a/netbox/virtualization/forms/model_forms.py
+++ b/netbox/virtualization/forms/model_forms.py
@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.forms.common import InterfaceCommonForm
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
+from extras.models import ConfigTemplate
from ipam.models import IPAddress, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
@@ -205,13 +206,18 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
required=False,
label=''
)
+ config_template = DynamicModelChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ required=False,
+ label=_('Config template')
+ )
comments = CommentField()
fieldsets = (
(_('Virtual Machine'), ('name', 'role', 'status', 'description', 'tags')),
(_('Site/Cluster'), ('site', 'cluster', 'device')),
(_('Tenancy'), ('tenant_group', 'tenant')),
- (_('Management'), ('platform', 'primary_ip4', 'primary_ip6')),
+ (_('Management'), ('platform', 'primary_ip4', 'primary_ip6', 'config_template')),
(_('Resources'), ('vcpus', 'memory', 'disk')),
(_('Config Context'), ('local_context_data',)),
)
@@ -221,6 +227,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
fields = [
'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags', 'local_context_data',
+ 'config_template',
]
def __init__(self, *args, **kwargs):
diff --git a/netbox/virtualization/migrations/0036_virtualmachine_config_template.py b/netbox/virtualization/migrations/0036_virtualmachine_config_template.py
new file mode 100644
index 000000000..0456eea81
--- /dev/null
+++ b/netbox/virtualization/migrations/0036_virtualmachine_config_template.py
@@ -0,0 +1,20 @@
+# Generated by Django 4.1.10 on 2023-08-11 17:16
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0098_webhook_custom_field_data_webhook_tags'),
+ ('virtualization', '0035_virtualmachine_interface_count'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='config_template',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'),
+ ),
+ ]
diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py
index 1cacd8adc..eb6c2a8b0 100644
--- a/netbox/virtualization/models/virtualmachines.py
+++ b/netbox/virtualization/models/virtualmachines.py
@@ -8,6 +8,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dcim.models import BaseInterface
+from dcim.models.mixins import RenderConfigMixin
from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet
from netbox.config import get_config
@@ -25,7 +26,7 @@ __all__ = (
)
-class VirtualMachine(ContactsMixin, PrimaryModel, ConfigContextModel):
+class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel):
"""
A virtual machine which runs inside a Cluster.
"""
diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py
index cece6f092..f8473df1e 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -84,13 +84,17 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
interface_count = tables.Column(
verbose_name=_('Interfaces')
)
+ config_template = tables.Column(
+ verbose_name=_('Config Template'),
+ linkify=True
+ )
class Meta(NetBoxTable.Meta):
model = VirtualMachine
fields = (
'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform',
'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments',
- 'contacts', 'tags', 'created', 'last_updated',
+ 'config_template', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index a4474610a..9c7748cbd 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -1,11 +1,14 @@
+import traceback
from collections import defaultdict
from django.contrib import messages
from django.db import transaction
from django.db.models import Prefetch, Sum
+from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
+from jinja2.exceptions import TemplateError
from dcim.filtersets import DeviceFilterSet
from dcim.models import Device
@@ -389,6 +392,55 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
)
+@register_model_view(VirtualMachine, 'render-config')
+class VirtualMachineRenderConfigView(generic.ObjectView):
+ queryset = VirtualMachine.objects.all()
+ template_name = 'virtualization/virtualmachine/render_config.html'
+ tab = ViewTab(
+ label=_('Render Config'),
+ permission='extras.view_configtemplate',
+ weight=2100
+ )
+
+ def get(self, request, **kwargs):
+ instance = self.get_object(**kwargs)
+ context = self.get_extra_context(request, instance)
+
+ # If a direct export has been requested, return the rendered template content as a
+ # downloadable file.
+ if request.GET.get('export'):
+ response = HttpResponse(context['rendered_config'], content_type='text')
+ filename = f"{instance.name or 'config'}.txt"
+ response['Content-Disposition'] = f'attachment; filename="{filename}"'
+ return response
+
+ return render(request, self.get_template_name(), {
+ 'object': instance,
+ 'tab': self.tab,
+ **context,
+ })
+
+ def get_extra_context(self, request, instance):
+ # Compile context data
+ context_data = instance.get_config_context()
+ context_data.update({'virtualmachine': instance})
+
+ # 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(VirtualMachine, 'edit')
class VirtualMachineEditView(generic.ObjectEditView):
queryset = VirtualMachine.objects.all()
|