Merge main into feature

This commit is contained in:
Jeremy Stretch
2025-04-10 17:17:21 -04:00
parent bb5057c063
commit fc0acb020f
197 changed files with 63438 additions and 53007 deletions

View File

@@ -112,15 +112,32 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
def validate(self, data):
# Validate many-to-many VLAN assignments
virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
for vlan in data.get('tagged_vlans', []):
if vlan.site not in [virtual_machine.site, None]:
raise serializers.ValidationError({
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
f"machine, or it must be global."
})
virtual_machine = None
tagged_vlans = []
# #18887
# There seem to be multiple code paths coming through here. Previously, we might either get
# the VirtualMachine instance from self.instance or from incoming data. However, #18887
# illustrated that this is also being called when a custom field pointing to an object_type
# of VMInterface is on the right side of a custom-field assignment coming in from an API
# request. As such, we need to check a third way to access the VirtualMachine
# instance--where `data` is the VMInterface instance itself and we can get the associated
# VirtualMachine via attribute access.
if isinstance(data, dict):
virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
tagged_vlans = data.get('tagged_vlans', [])
elif isinstance(data, VMInterface):
virtual_machine = data.virtual_machine
tagged_vlans = data.tagged_vlans.all()
if virtual_machine:
for vlan in tagged_vlans:
if vlan.site not in [virtual_machine.site, None]:
raise serializers.ValidationError({
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
f"machine, or it must be global."
})
return super().validate(data)

View File

@@ -1,7 +1,5 @@
from django.apps import AppConfig
from netbox import denormalized
class VirtualizationConfig(AppConfig):
name = 'virtualization'
@@ -15,10 +13,5 @@ class VirtualizationConfig(AppConfig):
# Register models
register_models(*self.get_models())
# Register denormalized fields
denormalized.register(VirtualMachine, 'cluster', {
'site': '_site',
})
# Register counters
connect_counters(VirtualMachine)

View File

@@ -38,6 +38,7 @@ class VirtualMachineStatusChoices(ChoiceSet):
STATUS_STAGED = 'staged'
STATUS_FAILED = 'failed'
STATUS_DECOMMISSIONING = 'decommissioning'
STATUS_PAUSED = 'paused'
CHOICES = [
(STATUS_OFFLINE, _('Offline'), 'gray'),
@@ -46,4 +47,5 @@ class VirtualMachineStatusChoices(ChoiceSet):
(STATUS_STAGED, _('Staged'), 'blue'),
(STATUS_FAILED, _('Failed'), 'red'),
(STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
(STATUS_PAUSED, _('Paused'), 'orange'),
]

View File

@@ -6,7 +6,7 @@ from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.forms.mixins import ScopedBulkEditForm
from dcim.models import Device, DeviceRole, Platform, Site
from extras.models import ConfigTemplate
from ipam.models import VLAN, VLANGroup, VRF
from ipam.models import VLAN, VLANGroup, VLANTranslationPolicy, VRF
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import BulkRenameForm, add_blank_choice
@@ -242,15 +242,23 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
required=False,
label=_('VRF')
)
vlan_translation_policy = DynamicModelChoiceField(
queryset=VLANTranslationPolicy.objects.all(),
required=False,
label=_('VLAN Translation Policy')
)
model = VMInterface
fieldsets = (
FieldSet('mtu', 'enabled', 'vrf', 'description'),
FieldSet('parent', 'bridge', name=_('Related Interfaces')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')),
FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy',
name=_('802.1Q Switching')
),
)
nullable_fields = (
'parent', 'bridge', 'mtu', 'vrf', 'description',
'parent', 'bridge', 'mtu', 'vrf', 'description', 'vlan_translation_policy',
)
def __init__(self, *args, **kwargs):

View File

@@ -49,7 +49,7 @@ class ComponentType(NetBoxObjectType):
filters=ClusterFilter,
pagination=True
)
class ClusterType(VLANGroupsMixin, NetBoxObjectType):
class ClusterType(ContactsMixin, VLANGroupsMixin, NetBoxObjectType):
type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None
group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@@ -72,7 +72,7 @@ class ClusterType(VLANGroupsMixin, NetBoxObjectType):
filters=ClusterGroupFilter,
pagination=True
)
class ClusterGroupType(VLANGroupsMixin, OrganizationalObjectType):
class ClusterGroupType(ContactsMixin, VLANGroupsMixin, OrganizationalObjectType):
clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]

View File

@@ -1,13 +1,14 @@
from django.db import migrations
from django.db.models import F, Sum
from netbox.settings import DISK_BASE_UNIT
def convert_disk_size(apps, schema_editor):
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
VirtualMachine.objects.filter(disk__isnull=False).update(disk=F('disk') * 1000)
VirtualMachine.objects.filter(disk__isnull=False).update(disk=F('disk') * DISK_BASE_UNIT)
VirtualDisk = apps.get_model('virtualization', 'VirtualDisk')
VirtualDisk.objects.filter(size__isnull=False).update(size=F('size') * 1000)
VirtualDisk.objects.filter(size__isnull=False).update(size=F('size') * DISK_BASE_UNIT)
# Recalculate disk size on all VMs with virtual disks
id_list = VirtualDisk.objects.values_list('virtual_machine_id').distinct()

View File

@@ -2,7 +2,7 @@ from django.db.models import Sum
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .models import VirtualDisk, VirtualMachine
from .models import Cluster, VirtualDisk, VirtualMachine
@receiver((post_delete, post_save), sender=VirtualDisk)
@@ -14,3 +14,12 @@ def update_virtualmachine_disk(instance, **kwargs):
VirtualMachine.objects.filter(pk=vm.pk).update(
disk=vm.virtualdisks.aggregate(Sum('size'))['size__sum']
)
@receiver(post_save, sender=Cluster)
def update_virtualmachine_site(instance, **kwargs):
"""
Update the assigned site for all VMs to match that of the Cluster (if any).
"""
if instance._site:
VirtualMachine.objects.filter(cluster=instance).update(site=instance._site)

View File

@@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.tables.devices import BaseInterfaceTable
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from utilities.templatetags.helpers import humanize_megabytes
from utilities.templatetags.helpers import humanize_disk_megabytes
from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
from .template_code import *
@@ -93,7 +93,7 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
)
def render_disk(self, value):
return humanize_megabytes(value)
return humanize_disk_megabytes(value)
#
@@ -122,7 +122,7 @@ class VMInterfaceTable(BaseInterfaceTable):
fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mtu', 'mode', 'description', 'tags', 'vrf',
'primary_mac_address', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'qinq_svlan', 'created', 'last_updated',
'qinq_svlan', 'created', 'last_updated', 'vlan_translation_policy',
)
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
@@ -183,7 +183,7 @@ class VirtualDiskTable(NetBoxTable):
}
def render_size(self, value):
return humanize_megabytes(value)
return humanize_disk_megabytes(value)
class VirtualMachineVirtualDiskTable(VirtualDiskTable):

View File

@@ -1,11 +1,15 @@
from django.test import tag
from django.urls import reverse
from netaddr import IPNetwork
from rest_framework import status
from core.models import ObjectType
from dcim.choices import InterfaceModeChoices
from dcim.models import Site
from extras.models import ConfigTemplate
from extras.choices import CustomFieldTypeChoices
from extras.models import ConfigTemplate, CustomField
from ipam.choices import VLANQinQRoleChoices
from ipam.models import VLAN, VRF
from ipam.models import Prefix, VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
from virtualization.choices import *
from virtualization.models import *
@@ -350,6 +354,39 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
},
]
@tag('regression')
def test_set_vminterface_as_object_in_custom_field(self):
cf = CustomField.objects.create(
name='associated_interface',
type=CustomFieldTypeChoices.TYPE_OBJECT,
related_object_type=ObjectType.objects.get_for_model(VMInterface),
required=False
)
cf.object_types.set([ObjectType.objects.get_for_model(Prefix)])
cf.save()
prefix = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/12'))
vmi = VMInterface.objects.first()
url = reverse('ipam-api:prefix-detail', kwargs={'pk': prefix.pk})
data = {
'custom_fields': {
'associated_interface': vmi.id,
},
}
self.add_permissions('ipam.change_prefix')
response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 200)
prefix_data = response.json()
self.assertEqual(prefix_data['custom_fields']['associated_interface']['id'], vmi.id)
reloaded_prefix = Prefix.objects.get(pk=prefix.pk)
self.assertEqual(prefix.pk, reloaded_prefix.pk)
self.assertNotEqual(reloaded_prefix.cf['associated_interface'], None)
def test_bulk_delete_child_interfaces(self):
interface1 = VMInterface.objects.get(name='Interface 1')
virtual_machine = interface1.virtual_machine

View File

@@ -4,13 +4,12 @@ from django.db.models import Prefetch, Sum
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from jinja2.exceptions import TemplateError
from dcim.filtersets import DeviceFilterSet
from dcim.forms import DeviceFilterForm
from dcim.models import Device
from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import IPAddress
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
@@ -415,51 +414,14 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
@register_model_view(VirtualMachine, 'render-config')
class VirtualMachineRenderConfigView(generic.ObjectView):
class VirtualMachineRenderConfigView(ObjectRenderConfigView):
queryset = VirtualMachine.objects.all()
template_name = 'virtualization/virtualmachine/render_config.html'
base_template = 'virtualization/virtualmachine/base.html'
tab = ViewTab(
label=_('Render Config'),
weight=2100
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 = context['config_template'].render_to_response(context=context['context_data'])
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
error_message = None
if config_template := instance.get_config_template():
try:
rendered_config = config_template.render(context=context_data)
except TemplateError as e:
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
return {
'config_template': config_template,
'context_data': context_data,
'rendered_config': rendered_config,
'error_message': error_message,
}
@register_model_view(VirtualMachine, 'add', detail=False)
@register_model_view(VirtualMachine, 'edit')