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

Adds config template to vm model (#13450)

* adds config template to vm model #12461

* Add translation tags; collapse config data

* i18n cleanup

* Establish parity with DeviceRenderConfigView

* Move config_template field to RenderConfigMixin

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
Abhimanyu Saharan
2023-08-15 01:13:28 +05:30
committed by GitHub
parent 8593715149
commit 752e26c7de
15 changed files with 232 additions and 28 deletions

View File

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

View File

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

View File

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

View File

@ -43,6 +43,10 @@
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Primary IPv4" %}</th>
<td>

View File

@ -0,0 +1,70 @@
{% extends 'virtualization/virtualmachine/base.html' %}
{% load static %}
{% load i18n %}
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-5">
<div class="card">
<h5 class="card-header">{% trans "Config Template" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ config_template|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Data Source" %}</th>
<td>{{ config_template.data_file.source|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Data File" %}</th>
<td>{{ config_template.data_file|linkify|placeholder }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-7">
<div class="card">
<div class="accordion accordion-flush" id="renderConfig">
<div class="card-body">
<div class="accordion-item">
<h2 class="accordion-header" id="renderConfigHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsedRenderConfig" aria-expanded="false" aria-controls="collapsedRenderConfig">
{% trans "Context Data" %}
</button>
</h2>
<div id="collapsedRenderConfig" class="accordion-collapse collapse" aria-labelledby="renderConfigHeading" data-bs-parent="#renderConfig">
<div class="accordion-body">
<pre class="card-body">{{ context_data|pprint }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">
<div class="float-end">
<a href="?export=True" class="btn btn-sm btn-primary" role="button">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
</a>
</div>
<h5>{% trans "Rendered Config" %}</h5>
</div>
{% if config_template %}
<pre class="card-body">{{ rendered_config }}</pre>
{% else %}
<div class="card-body text-muted">{% trans "No configuration template found" %}</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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