Closes #8356: Add virtual disk to Virtual Machines (#14087)

* 8356 add virtual disk model

* 8356 add supplemental forms

* 8356 add menu

* 8356 cleanup views

* 8356 virtual machine tab

* 8356 migrations

* 8356 vm disk tables

* 8356 cleanup

* 8356 graphql

* 8356 graphql

* 8356 add components button

* 8356 bulk add on virtualmachine

* 8356 bulk add fixes

* 8356 api tests

* 8356 news tests add rename

* 8356 VirtualDiskCreateForm

* 8356 fix test

* 8356 add todo to remove disk from vm

* 8356 review changes

* 8356 fix test

* 8356 deprecate disk field

* 8356 review changes

* 8356 fix test

* 8356 fix test

* Simplify view actions

* 8356 review changes

* 8356 split trans tag

* 8356 add total virtual disk size to api

* 8356 add virtual disk list to virtual machine detail view

* 8356 move virtual disk size to property

* 8356 revert property

* Tweak display of deprecated disk field

* 8356 render single disk field

* 8356 update serializer

* 8356 model property

* 8356 fix test

* 8356 review changes

* Revert disk space annotation

* Use existing disk field to store aggregate virtual disk size

* Introduce abstract ComponentModel for VM components

* Add search index for VirtualDisk

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
Arthur Hanson 2023-11-17 12:02:56 -08:00 committed by GitHub
parent e13bf48a35
commit 549b0ea107
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 804 additions and 63 deletions

View File

@ -218,6 +218,7 @@ VIRTUALIZATION_MENU = Menu(
items=( items=(
get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')), get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')),
get_model_item('virtualization', 'vminterface', _('Interfaces')), get_model_item('virtualization', 'vminterface', _('Interfaces')),
get_model_item('virtualization', 'virtualdisk', _('Virtual Disks')),
), ),
), ),
MenuGroup( MenuGroup(

View File

@ -0,0 +1,59 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'virtualization:virtualmachine_disks' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
</li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Virtual Disk" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Virtual Machine" %}</th>
<td>{{ object.virtual_machine|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Size" %}</th>
<td>
{% if object.size %}
{{ object.size }} {% trans "GB" context "Abbreviation for gigabyte" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -139,14 +139,16 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}</th> <th scope="row">
<td> <i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
{% if object.disk %} </th>
{{ object.disk }} {% trans "GB" context "Abbreviation for gigabyte" %} <td>
{% else %} {% if object.disk %}
{{ ''|placeholder }} {{ object.disk }} {% trans "GB" context "Abbreviation for gigabyte" %}
{% endif %} {% else %}
</td> {{ ''|placeholder }}
{% endif %}
</td>
</tr> </tr>
</table> </table>
</div> </div>
@ -168,6 +170,26 @@
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Virtual Disks" %}</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'virtualization:virtualdisk_list' %}?virtual_machine_id={{ object.pk }}"
hx-trigger="load"
></div>
{% if perms.virtualization.add_virtualdisk %}
<div class="card-footer text-end noprint">
<a href="{% url 'virtualization:virtualdisk_add' %}?device={{ object.device.pk }}&virtual_machine={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Virtual Disk" %}
</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% plugin_full_width_page object %} {% plugin_full_width_page object %}

View File

@ -16,9 +16,23 @@
{% endblock %} {% endblock %}
{% block extra_controls %} {% block extra_controls %}
{% if perms.virtualization.add_vminterface %}
<a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-sm btn-primary"> <div class="dropdown">
<i class="mdi mdi-plus-thick"></i> {% trans "Add Interfaces" %} <button id="add-components" type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
</a> <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
{% endif %} </button>
<ul class="dropdown-menu" aria-labeled-by="add-components">
{% if perms.virtualization.add_vminterface %}
<li><a class="dropdown-item" href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">
{% trans "Interfaces" %}
</a></li>
{% endif %}
{% if perms.virtualization.add_virtualdisk %}
<li><a class="dropdown-item" href="{% url 'virtualization:virtualdisk_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_disks' pk=object.pk %}">
{% trans "Virtual Disks" %}
</a></li>
{% endif %}
</ul>
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'generic/object_children.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_edit_controls %}
{{ block.super }}
{% if 'bulk_rename' in actions %}
<button type="submit" name="_rename"
formaction="{% url 'virtualization:virtualdisk_bulk_rename' %}?return_url={{ return_url }}"
class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
{% endif %}
{% endblock bulk_edit_controls %}

View File

@ -15,6 +15,13 @@
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if perms.virtualization.add_virtualdisk %}
<li>
<button type="submit" formaction="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Virtual Disks" %}
</button>
</li>
{% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View File

@ -2,12 +2,13 @@ from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers from rest_framework import serializers
from netbox.api.serializers import WritableNestedSerializer from netbox.api.serializers import WritableNestedSerializer
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import *
__all__ = [ __all__ = [
'NestedClusterGroupSerializer', 'NestedClusterGroupSerializer',
'NestedClusterSerializer', 'NestedClusterSerializer',
'NestedClusterTypeSerializer', 'NestedClusterTypeSerializer',
'NestedVirtualDiskSerializer',
'NestedVMInterfaceSerializer', 'NestedVMInterfaceSerializer',
'NestedVirtualMachineSerializer', 'NestedVirtualMachineSerializer',
] ]
@ -72,3 +73,12 @@ class NestedVMInterfaceSerializer(WritableNestedSerializer):
class Meta: class Meta:
model = VMInterface model = VMInterface
fields = ['id', 'url', 'display', 'virtual_machine', 'name'] fields = ['id', 'url', 'display', 'virtual_machine', 'name']
class NestedVirtualDiskSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualdisk-detail')
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
class Meta:
model = VirtualDisk
fields = ['id', 'url', 'display', 'virtual_machine', 'name', 'size']

View File

@ -14,7 +14,7 @@ from netbox.api.fields import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from virtualization.choices import * from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualDisk, VirtualMachine, VMInterface
from .nested_serializers import * from .nested_serializers import *
@ -84,6 +84,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
# Counter fields # Counter fields
interface_count = serializers.IntegerField(read_only=True) interface_count = serializers.IntegerField(read_only=True)
virtual_disk_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
@ -91,7 +92,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
'interface_count', 'interface_count', 'virtual_disk_count',
] ]
validators = [] validators = []
@ -104,7 +105,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'interface_count', 'interface_count', 'virtual_disk_count',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
@ -159,3 +160,19 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
}) })
return super().validate(data) return super().validate(data)
#
# Virtual Disk
#
class VirtualDiskSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualdisk-detail')
virtual_machine = NestedVirtualMachineSerializer()
class Meta:
model = VirtualDisk
fields = [
'id', 'url', 'virtual_machine', 'name', 'description', 'size', 'tags', 'custom_fields', 'created',
'last_updated',
]

View File

@ -13,6 +13,7 @@ router.register('clusters', views.ClusterViewSet)
# VirtualMachines # VirtualMachines
router.register('virtual-machines', views.VirtualMachineViewSet) router.register('virtual-machines', views.VirtualMachineViewSet)
router.register('interfaces', views.VMInterfaceViewSet) router.register('interfaces', views.VMInterfaceViewSet)
router.register('virtual-disks', views.VirtualDiskViewSet)
app_name = 'virtualization-api' app_name = 'virtualization-api'
urlpatterns = router.urls urlpatterns = router.urls

View File

@ -6,7 +6,7 @@ from netbox.api.viewsets import NetBoxModelViewSet
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.utils import count_related from utilities.utils import count_related
from virtualization import filtersets from virtualization import filtersets
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import *
from . import serializers from . import serializers
@ -55,7 +55,8 @@ class ClusterViewSet(NetBoxModelViewSet):
class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related( queryset = VirtualMachine.objects.prefetch_related(
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template', 'tags' 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template',
'tags', 'virtualdisks',
) )
filterset_class = filtersets.VirtualMachineFilterSet filterset_class = filtersets.VirtualMachineFilterSet
@ -92,3 +93,12 @@ class VMInterfaceViewSet(NetBoxModelViewSet):
def get_bulk_destroy_queryset(self): def get_bulk_destroy_queryset(self):
# Ensure child interfaces are deleted prior to their parents # Ensure child interfaces are deleted prior to their parents
return self.get_queryset().order_by('virtual_machine', 'parent', CollateAsChar('_name')) return self.get_queryset().order_by('virtual_machine', 'parent', CollateAsChar('_name'))
class VirtualDiskViewSet(NetBoxModelViewSet):
queryset = VirtualDisk.objects.prefetch_related(
'virtual_machine', 'tags',
)
serializer_class = serializers.VirtualDiskSerializer
filterset_class = filtersets.VirtualDiskFilterSet
brief_prefetch_fields = ['virtual_machine']

View File

@ -5,7 +5,7 @@ class VirtualizationConfig(AppConfig):
name = 'virtualization' name = 'virtualization'
def ready(self): def ready(self):
from . import search from . import search, signals
from .models import VirtualMachine from .models import VirtualMachine
from utilities.counters import connect_counters from utilities.counters import connect_counters

View File

@ -11,12 +11,13 @@ from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
from .choices import * from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .models import *
__all__ = ( __all__ = (
'ClusterFilterSet', 'ClusterFilterSet',
'ClusterGroupFilterSet', 'ClusterGroupFilterSet',
'ClusterTypeFilterSet', 'ClusterTypeFilterSet',
'VirtualDiskFilterSet',
'VirtualMachineFilterSet', 'VirtualMachineFilterSet',
'VMInterfaceFilterSet', 'VMInterfaceFilterSet',
) )
@ -305,3 +306,29 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
Q(name__icontains=value) | Q(name__icontains=value) |
Q(description__icontains=value) Q(description__icontains=value)
) )
class VirtualDiskFilterSet(NetBoxModelFilterSet):
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine',
queryset=VirtualMachine.objects.all(),
label=_('Virtual machine (ID)'),
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine__name',
queryset=VirtualMachine.objects.all(),
to_field_name='name',
label=_('Virtual machine'),
)
class Meta:
model = VirtualDisk
fields = ['id', 'name', 'size', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)

View File

@ -3,9 +3,10 @@ from django.utils.translation import gettext_lazy as _
from utilities.forms import BootstrapMixin, form_from_model from utilities.forms import BootstrapMixin, form_from_model
from utilities.forms.fields import ExpandableNameField from utilities.forms.fields import ExpandableNameField
from virtualization.models import VMInterface, VirtualMachine from virtualization.models import VirtualDisk, VMInterface, VirtualMachine
__all__ = ( __all__ = (
'VirtualDiskBulkCreateForm',
'VMInterfaceBulkCreateForm', 'VMInterfaceBulkCreateForm',
) )
@ -30,3 +31,10 @@ class VMInterfaceBulkCreateForm(
VirtualMachineBulkAddComponentForm VirtualMachineBulkAddComponentForm
): ):
replication_fields = ('name',) replication_fields = ('name',)
class VirtualDiskBulkCreateForm(
form_from_model(VirtualDisk, ['size', 'description', 'tags']),
VirtualMachineBulkAddComponentForm
):
replication_fields = ('name',)

View File

@ -18,6 +18,8 @@ __all__ = (
'ClusterBulkEditForm', 'ClusterBulkEditForm',
'ClusterGroupBulkEditForm', 'ClusterGroupBulkEditForm',
'ClusterTypeBulkEditForm', 'ClusterTypeBulkEditForm',
'VirtualDiskBulkEditForm',
'VirtualDiskBulkRenameForm',
'VirtualMachineBulkEditForm', 'VirtualMachineBulkEditForm',
'VMInterfaceBulkEditForm', 'VMInterfaceBulkEditForm',
'VMInterfaceBulkRenameForm', 'VMInterfaceBulkRenameForm',
@ -315,3 +317,35 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
class VirtualDiskBulkEditForm(NetBoxModelBulkEditForm):
virtual_machine = forms.ModelChoiceField(
label=_('Virtual machine'),
queryset=VirtualMachine.objects.all(),
required=False,
disabled=True,
widget=forms.HiddenInput()
)
size = forms.IntegerField(
required=False,
label=_('Size (GB)')
)
description = forms.CharField(
label=_('Description'),
max_length=100,
required=False
)
model = VirtualDisk
fieldsets = (
(None, ('size', 'description')),
)
nullable_fields = ('description',)
class VirtualDiskBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(
queryset=VirtualDisk.objects.all(),
widget=forms.MultipleHiddenInput()
)

View File

@ -14,6 +14,7 @@ __all__ = (
'ClusterImportForm', 'ClusterImportForm',
'ClusterGroupImportForm', 'ClusterGroupImportForm',
'ClusterTypeImportForm', 'ClusterTypeImportForm',
'VirtualDiskImportForm',
'VirtualMachineImportForm', 'VirtualMachineImportForm',
'VMInterfaceImportForm', 'VMInterfaceImportForm',
) )
@ -199,3 +200,17 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
return True return True
else: else:
return self.cleaned_data['enabled'] return self.cleaned_data['enabled']
class VirtualDiskImportForm(NetBoxModelImportForm):
virtual_machine = CSVModelChoiceField(
label=_('Virtual machine'),
queryset=VirtualMachine.objects.all(),
to_field_name='name'
)
class Meta:
model = VirtualDisk
fields = (
'virtual_machine', 'name', 'size', 'description', 'tags'
)

View File

@ -16,6 +16,7 @@ __all__ = (
'ClusterFilterForm', 'ClusterFilterForm',
'ClusterGroupFilterForm', 'ClusterGroupFilterForm',
'ClusterTypeFilterForm', 'ClusterTypeFilterForm',
'VirtualDiskFilterForm',
'VirtualMachineFilterForm', 'VirtualMachineFilterForm',
'VMInterfaceFilterForm', 'VMInterfaceFilterForm',
) )
@ -221,3 +222,23 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
label=_('L2VPN') label=_('L2VPN')
) )
tag = TagFilterField(model) tag = TagFilterField(model)
class VirtualDiskFilterForm(NetBoxModelFilterSetForm):
model = VirtualDisk
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Virtual Machine'), ('virtual_machine_id',)),
(_('Attributes'), ('size',)),
)
virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label=_('Virtual machine')
)
size = forms.IntegerField(
label=_('Size (GB)'),
required=False,
min_value=1
)
tag = TagFilterField(model)

View File

@ -22,6 +22,7 @@ __all__ = (
'ClusterGroupForm', 'ClusterGroupForm',
'ClusterRemoveDevicesForm', 'ClusterRemoveDevicesForm',
'ClusterTypeForm', 'ClusterTypeForm',
'VirtualDiskForm',
'VirtualMachineForm', 'VirtualMachineForm',
'VMInterfaceForm', 'VMInterfaceForm',
) )
@ -240,6 +241,11 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
if self.instance.pk: if self.instance.pk:
# Disable the disk field if one or more VirtualDisks have been created
if self.instance.virtualdisks.exists():
self.fields['disk'].widget.attrs['disabled'] = True
self.fields['disk'].help_text = _("Disk size is managed via the attachment of virtual disks.")
# Compile list of choices for primary IPv4 and IPv6 addresses # Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]: for family in [4, 6]:
ip_choices = [(None, '---------')] ip_choices = [(None, '---------')]
@ -276,12 +282,26 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
self.fields['primary_ip6'].widget.attrs['readonly'] = True self.fields['primary_ip6'].widget.attrs['readonly'] = True
class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): #
# Virtual machine components
#
class VMComponentForm(NetBoxModelForm):
virtual_machine = DynamicModelChoiceField( virtual_machine = DynamicModelChoiceField(
label=_('Virtual machine'), label=_('Virtual machine'),
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
selector=True selector=True
) )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Disable reassignment of VirtualMachine when editing an existing instance
if self.instance.pk:
self.fields['virtual_machine'].disabled = True
class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
required=False, required=False,
@ -348,9 +368,15 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
'mode': HTMXSelect(), 'mode': HTMXSelect(),
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Disable reassignment of VirtualMachine when editing an existing instance class VirtualDiskForm(VMComponentForm):
if self.instance.pk:
self.fields['virtual_machine'].disabled = True fieldsets = (
(_('Disk'), ('virtual_machine', 'name', 'size', 'description', 'tags')),
)
class Meta:
model = VirtualDisk
fields = [
'virtual_machine', 'name', 'size', 'description', 'tags',
]

View File

@ -1,8 +1,9 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from utilities.forms.fields import ExpandableNameField from utilities.forms.fields import ExpandableNameField
from .model_forms import VMInterfaceForm from .model_forms import VirtualDiskForm, VMInterfaceForm
__all__ = ( __all__ = (
'VirtualDiskCreateForm',
'VMInterfaceCreateForm', 'VMInterfaceCreateForm',
) )
@ -15,3 +16,13 @@ class VMInterfaceCreateForm(VMInterfaceForm):
class Meta(VMInterfaceForm.Meta): class Meta(VMInterfaceForm.Meta):
exclude = ('name',) exclude = ('name',)
class VirtualDiskCreateForm(VirtualDiskForm):
name = ExpandableNameField(
label=_('Name'),
)
replication_fields = ('name',)
class Meta(VirtualDiskForm.Meta):
exclude = ('name',)

View File

@ -36,3 +36,9 @@ class VirtualizationQuery(graphene.ObjectType):
def resolve_vm_interface_list(root, info, **kwargs): def resolve_vm_interface_list(root, info, **kwargs):
return gql_query_optimizer(models.VMInterface.objects.all(), info) return gql_query_optimizer(models.VMInterface.objects.all(), info)
virtual_disk = ObjectField(VirtualDiskType)
virtual_disk_list = ObjectListField(VirtualDiskType)
def resolve_virtual_disk_list(root, info, **kwargs):
return gql_query_optimizer(models.VirtualDisk.objects.all(), info)

View File

@ -8,6 +8,7 @@ __all__ = (
'ClusterType', 'ClusterType',
'ClusterGroupType', 'ClusterGroupType',
'ClusterTypeType', 'ClusterTypeType',
'VirtualDiskType',
'VirtualMachineType', 'VirtualMachineType',
'VMInterfaceType', 'VMInterfaceType',
) )
@ -54,3 +55,14 @@ class VMInterfaceType(IPAddressesMixin, ComponentObjectType):
def resolve_mode(self, info): def resolve_mode(self, info):
return self.mode or None return self.mode or None
class VirtualDiskType(ComponentObjectType):
class Meta:
model = models.VirtualDisk
fields = '__all__'
filterset_class = filtersets.VirtualDiskFilterSet
def resolve_mode(self, info):
return self.mode or None

View File

@ -0,0 +1,50 @@
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import utilities.fields
import utilities.json
import utilities.ordering
import utilities.query_functions
import utilities.tracking
class Migration(migrations.Migration):
dependencies = [
('extras', '0099_cachedvalue_ordering'),
('virtualization', '0037_protect_child_interfaces'),
]
operations = [
migrations.AddField(
model_name='virtualmachine',
name='virtual_disk_count',
field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='virtual_machine', to_model='virtualization.VirtualDisk'),
),
migrations.CreateModel(
name='VirtualDisk',
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)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface)),
('description', models.CharField(blank=True, max_length=200)),
('size', models.PositiveIntegerField()),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='virtualization.virtualmachine')),
],
options={
'verbose_name': 'virtual disk',
'verbose_name_plural': 'virtual disks',
'ordering': ('virtual_machine', utilities.query_functions.CollateAsChar('_name')),
'abstract': False,
},
bases=(models.Model, utilities.tracking.TrackingModelMixin),
),
migrations.AddConstraint(
model_name='virtualdisk',
constraint=models.UniqueConstraint(fields=('virtual_machine', 'name'), name='virtualization_virtualdisk_unique_virtual_machine_name'),
),
]

View File

@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q, Sum
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -21,6 +21,7 @@ from utilities.tracking import TrackingModelMixin
from virtualization.choices import * from virtualization.choices import *
__all__ = ( __all__ = (
'VirtualDisk',
'VirtualMachine', 'VirtualMachine',
'VMInterface', 'VMInterface',
) )
@ -130,6 +131,10 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
to_model='virtualization.VMInterface', to_model='virtualization.VMInterface',
to_field='virtual_machine' to_field='virtual_machine'
) )
virtual_disk_count = CounterCacheField(
to_model='virtualization.VirtualDisk',
to_field='virtual_machine'
)
objects = ConfigContextModelQuerySet.as_manager() objects = ConfigContextModelQuerySet.as_manager()
@ -192,6 +197,17 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
).format(device=self.device, cluster=self.cluster) ).format(device=self.device, cluster=self.cluster)
}) })
# Validate aggregate disk size
if self.pk:
total_disk = self.virtualdisks.aggregate(Sum('size', default=0))['size__sum']
if total_disk and self.disk != total_disk:
raise ValidationError({
'disk': _(
"The specified disk size ({size}) must match the aggregate size of assigned virtual disks "
"({total_size})."
).format(size=self.disk, total_size=total_disk)
})
# Validate primary IP addresses # Validate primary IP addresses
interfaces = self.interfaces.all() if self.pk else None interfaces = self.interfaces.all() if self.pk else None
for family in (4, 6): for family in (4, 6):
@ -236,11 +252,19 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
return None return None
class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): #
# VM components
#
class ComponentModel(NetBoxModel):
"""
An abstract model inherited by any model which has a parent VirtualMachine.
"""
virtual_machine = models.ForeignKey( virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine', to='virtualization.VirtualMachine',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='interfaces' related_name='%(class)ss'
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
@ -257,6 +281,42 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
max_length=200, max_length=200,
blank=True blank=True
) )
class Meta:
abstract = True
ordering = ('virtual_machine', CollateAsChar('_name'))
constraints = (
models.UniqueConstraint(
fields=('virtual_machine', 'name'),
name='%(app_label)s_%(class)s_unique_virtual_machine_name'
),
)
def __str__(self):
return self.name
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
objectchange.related_object = self.virtual_machine
return objectchange
@property
def parent_object(self):
return self.virtual_machine
class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.CASCADE,
related_name='interfaces' # Override ComponentModel
)
_name = NaturalOrderingField(
target_field='name',
naturalize_function=naturalize_interface,
max_length=100,
blank=True
)
untagged_vlan = models.ForeignKey( untagged_vlan = models.ForeignKey(
to='ipam.VLAN', to='ipam.VLAN',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -298,20 +358,10 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
related_query_name='vminterface', related_query_name='vminterface',
) )
class Meta: class Meta(ComponentModel.Meta):
ordering = ('virtual_machine', CollateAsChar('_name'))
constraints = (
models.UniqueConstraint(
fields=('virtual_machine', 'name'),
name='%(app_label)s_%(class)s_unique_virtual_machine_name'
),
)
verbose_name = _('interface') verbose_name = _('interface')
verbose_name_plural = _('interfaces') verbose_name_plural = _('interfaces')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return reverse('virtualization:vminterface', kwargs={'pk': self.pk}) return reverse('virtualization:vminterface', kwargs={'pk': self.pk})
@ -359,15 +409,19 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
).format(untagged_vlan=self.untagged_vlan) ).format(untagged_vlan=self.untagged_vlan)
}) })
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
objectchange.related_object = self.virtual_machine
return objectchange
@property
def parent_object(self):
return self.virtual_machine
@property @property
def l2vpn_termination(self): def l2vpn_termination(self):
return self.l2vpn_terminations.first() return self.l2vpn_terminations.first()
class VirtualDisk(ComponentModel, TrackingModelMixin):
size = models.PositiveIntegerField(
verbose_name=_('size (GB)'),
)
class Meta(ComponentModel.Meta):
verbose_name = _('virtual disk')
verbose_name_plural = _('virtual disks')
def get_absolute_url(self):
return reverse('virtualization:virtualdisk', args=[self.pk])

View File

@ -56,3 +56,13 @@ class VMInterfaceIndex(SearchIndex):
('mtu', 2000), ('mtu', 2000),
) )
display_attrs = ('virtual_machine', 'description') display_attrs = ('virtual_machine', 'description')
@register_search
class VirtualDiskIndex(SearchIndex):
model = models.VirtualDisk
fields = (
('name', 100),
('description', 500),
)
display_attrs = ('virtual_machine', 'description')

View File

@ -0,0 +1,16 @@
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
@receiver((post_delete, post_save), sender=VirtualDisk)
def update_virtualmachine_disk(instance, **kwargs):
"""
When a VirtualDisk has been modified, update the aggregate disk_size value of its VM.
"""
vm = instance.virtual_machine
VirtualMachine.objects.filter(pk=vm.pk).update(
disk=vm.virtualdisks.aggregate(Sum('size'))['size__sum']
)

View File

@ -4,10 +4,12 @@ from django.utils.translation import gettext_lazy as _
from dcim.tables.devices import BaseInterfaceTable from dcim.tables.devices import BaseInterfaceTable
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
__all__ = ( __all__ = (
'VirtualDiskTable',
'VirtualMachineTable', 'VirtualMachineTable',
'VirtualMachineVirtualDiskTable',
'VirtualMachineVMInterfaceTable', 'VirtualMachineVMInterfaceTable',
'VMInterfaceTable', 'VMInterfaceTable',
) )
@ -84,6 +86,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
interface_count = tables.Column( interface_count = tables.Column(
verbose_name=_('Interfaces') verbose_name=_('Interfaces')
) )
virtual_disk_count = tables.Column(
verbose_name=_('Virtual Disks')
)
config_template = tables.Column( config_template = tables.Column(
verbose_name=_('Config Template'), verbose_name=_('Config Template'),
linkify=True linkify=True
@ -155,3 +160,39 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
row_attrs = { row_attrs = {
'data-name': lambda record: record.name, 'data-name': lambda record: record.name,
} }
class VirtualDiskTable(NetBoxTable):
virtual_machine = tables.Column(
verbose_name=_('Virtual Machine'),
linkify=True
)
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
tags = columns.TagColumn(
url_name='virtualization:virtualdisk_list'
)
class Meta(NetBoxTable.Meta):
model = VirtualDisk
fields = (
'pk', 'id', 'virtual_machine', 'name', 'size', 'description', 'tags',
)
default_columns = ('pk', 'name', 'virtual_machine', 'size', 'description')
row_attrs = {
'data-name': lambda record: record.name,
}
class VirtualMachineVirtualDiskTable(VirtualDiskTable):
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
)
class Meta(VirtualDiskTable.Meta):
fields = (
'pk', 'id', 'name', 'size', 'description', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'size', 'description')

View File

@ -5,9 +5,9 @@ from dcim.choices import InterfaceModeChoices
from dcim.models import Site from dcim.models import Site
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.models import VLAN, VRF from ipam.models import VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases, create_test_device from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
from virtualization.choices import * from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import *
class AppTest(APITestCase): class AppTest(APITestCase):
@ -256,10 +256,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
virtualmachine = create_test_virtualmachine('Virtual Machine 1')
clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype)
virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1')
interfaces = ( interfaces = (
VMInterface(virtual_machine=virtualmachine, name='Interface 1'), VMInterface(virtual_machine=virtualmachine, name='Interface 1'),
@ -336,3 +333,41 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
] ]
self.client.delete(self._get_list_url(), data, format='json', **self.header) self.client.delete(self._get_list_url(), data, format='json', **self.header)
self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted
class VirtualDiskTest(APIViewTestCases.APIViewTestCase):
model = VirtualDisk
brief_fields = ['display', 'id', 'name', 'size', 'url', 'virtual_machine']
bulk_update_data = {
'size': 888,
}
graphql_base_name = 'virtual_disk'
@classmethod
def setUpTestData(cls):
virtualmachine = create_test_virtualmachine('Virtual Machine 1')
disks = (
VirtualDisk(virtual_machine=virtualmachine, name='Disk 1', size=10),
VirtualDisk(virtual_machine=virtualmachine, name='Disk 2', size=20),
VirtualDisk(virtual_machine=virtualmachine, name='Disk 3', size=30),
)
VirtualDisk.objects.bulk_create(disks)
cls.create_data = [
{
'virtual_machine': virtualmachine.pk,
'name': 'Disk 4',
'size': 10,
},
{
'virtual_machine': virtualmachine.pk,
'name': 'Disk 5',
'size': 20,
},
{
'virtual_machine': virtualmachine.pk,
'name': 'Disk 6',
'size': 30,
},
]

View File

@ -6,7 +6,7 @@ from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.choices import * from virtualization.choices import *
from virtualization.filtersets import * from virtualization.filtersets import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import *
class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests): class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
@ -534,3 +534,46 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_description(self): def test_description(self):
params = {'description': ['foobar1', 'foobar2']} params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualDiskTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualDisk.objects.all()
filterset = VirtualDiskFilterSet
@classmethod
def setUpTestData(cls):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type)
vms = (
VirtualMachine(name='Virtual Machine 1', cluster=cluster),
VirtualMachine(name='Virtual Machine 2', cluster=cluster),
VirtualMachine(name='Virtual Machine 3', cluster=cluster),
)
VirtualMachine.objects.bulk_create(vms)
disks = (
VirtualDisk(virtual_machine=vms[0], name='Disk 1', size=1, description='A'),
VirtualDisk(virtual_machine=vms[1], name='Disk 2', size=2, description='B'),
VirtualDisk(virtual_machine=vms[2], name='Disk 3', size=3, description='C'),
)
VirtualDisk.objects.bulk_create(disks)
def test_virtual_machine(self):
vms = VirtualMachine.objects.all()[:2]
params = {'virtual_machine_id': [vms[0].pk, vms[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'virtual_machine': [vms[0].name, vms[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['Disk 1', 'Disk 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_size(self):
params = {'size': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -90,3 +90,28 @@ class VirtualMachineTestCase(TestCase):
# Uniqueness validation for name should ignore case # Uniqueness validation for name should ignore case
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
vm2.full_clean() vm2.full_clean()
def test_disk_size(self):
vm = VirtualMachine(
cluster=Cluster.objects.first(),
name='Virtual Machine 1'
)
vm.save()
vm.refresh_from_db()
self.assertEqual(vm.disk, None)
# Create two VirtualDisks
VirtualDisk.objects.create(virtual_machine=vm, name='Virtual Disk 1', size=10)
VirtualDisk.objects.create(virtual_machine=vm, name='Virtual Disk 2', size=10)
vm.refresh_from_db()
self.assertEqual(vm.disk, 20)
# Delete one VirtualDisk
VirtualDisk.objects.first().delete()
vm.refresh_from_db()
self.assertEqual(vm.disk, 10)
# Attempt to manually overwrite the aggregate disk size
vm.disk = 30
with self.assertRaises(ValidationError):
vm.full_clean()

View File

@ -5,9 +5,9 @@ from netaddr import EUI
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site from dcim.models import DeviceRole, Platform, Site
from ipam.models import VLAN, VRF from ipam.models import VLAN, VRF
from utilities.testing import ViewTestCases, create_tags, create_test_device from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine
from virtualization.choices import * from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import *
class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@ -403,3 +403,54 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
} }
self.client.post(self._get_url('bulk_delete'), data) self.client.post(self._get_url('bulk_delete'), data)
self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted
class VirtualDiskTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = VirtualDisk
validation_excluded_fields = ('name',)
@classmethod
def setUpTestData(cls):
virtualmachine = create_test_virtualmachine('Virtual Machine 1')
disks = VirtualDisk.objects.bulk_create([
VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 1', size=10),
VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 2', size=10),
VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 3', size=10),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'virtual_machine': virtualmachine.pk,
'name': 'Virtual Disk X',
'size': 20,
'description': 'New description',
'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
'virtual_machine': virtualmachine.pk,
'name': 'Virtual Disk [4-6]',
'size': 10,
'tags': [t.pk for t in tags],
}
cls.csv_data = (
f"virtual_machine,name,size,description",
f"Virtual Machine 1,Disk 4,20,Fourth",
f"Virtual Machine 1,Disk 5,20,Fifth",
f"Virtual Machine 1,Disk 6,20,Sixth",
)
cls.csv_update_data = (
f"id,name,size",
f"{disks[0].pk},disk1,20",
f"{disks[1].pk},disk2,20",
f"{disks[2].pk},disk3,20",
)
cls.bulk_edit_data = {
'size': 30,
'description': 'New description',
}

View File

@ -48,4 +48,13 @@ urlpatterns = [
path('interfaces/<int:pk>/', include(get_model_urls('virtualization', 'vminterface'))), path('interfaces/<int:pk>/', include(get_model_urls('virtualization', 'vminterface'))),
path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'), path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'),
# Virtual disks
path('disks/', views.VirtualDiskListView.as_view(), name='virtualdisk_list'),
path('disks/add/', views.VirtualDiskCreateView.as_view(), name='virtualdisk_add'),
path('disks/import/', views.VirtualDiskBulkImportView.as_view(), name='virtualdisk_import'),
path('disks/edit/', views.VirtualDiskBulkEditView.as_view(), name='virtualdisk_bulk_edit'),
path('disks/rename/', views.VirtualDiskBulkRenameView.as_view(), name='virtualdisk_bulk_rename'),
path('disks/delete/', views.VirtualDiskBulkDeleteView.as_view(), name='virtualdisk_bulk_delete'),
path('disks/<int:pk>/', include(get_model_urls('virtualization', 'virtualdisk'))),
path('virtual-machines/disks/add/', views.VirtualMachineBulkAddVirtualDiskView.as_view(), name='virtualmachine_bulk_add_virtualdisk'),
] ]

View File

@ -22,7 +22,7 @@ from utilities.query_functions import CollateAsChar
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view from utilities.views import ViewTab, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .models import *
# #
@ -378,6 +378,28 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
) )
@register_model_view(VirtualMachine, 'disks')
class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
queryset = VirtualMachine.objects.all()
child_model = VirtualDisk
table = tables.VirtualMachineVirtualDiskTable
filterset = filtersets.VirtualDiskFilterSet
template_name = 'virtualization/virtualmachine/virtual_disks.html'
tab = ViewTab(
label=_('Virtual Disks'),
badge=lambda obj: obj.virtual_disk_count,
permission='virtualization.view_virtual_disk',
weight=500
)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
def get_children(self, request, parent):
return parent.virtualdisks.restrict(request.user, 'view').prefetch_related('tags')
@register_model_view(VirtualMachine, 'configcontext', path='config-context') @register_model_view(VirtualMachine, 'configcontext', path='config-context')
class VirtualMachineConfigContextView(ObjectConfigContextView): class VirtualMachineConfigContextView(ObjectConfigContextView):
queryset = VirtualMachine.objects.annotate_config_context_data() queryset = VirtualMachine.objects.annotate_config_context_data()
@ -556,6 +578,62 @@ class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
table = tables.VMInterfaceTable table = tables.VMInterfaceTable
#
# Virtual disks
#
class VirtualDiskListView(generic.ObjectListView):
queryset = VirtualDisk.objects.all()
filterset = filtersets.VirtualDiskFilterSet
filterset_form = forms.VirtualDiskFilterForm
table = tables.VirtualDiskTable
@register_model_view(VirtualDisk)
class VirtualDiskView(generic.ObjectView):
queryset = VirtualDisk.objects.all()
class VirtualDiskCreateView(generic.ComponentCreateView):
queryset = VirtualDisk.objects.all()
form = forms.VirtualDiskCreateForm
model_form = forms.VirtualDiskForm
@register_model_view(VirtualDisk, 'edit')
class VirtualDiskEditView(generic.ObjectEditView):
queryset = VirtualDisk.objects.all()
form = forms.VirtualDiskForm
@register_model_view(VirtualDisk, 'delete')
class VirtualDiskDeleteView(generic.ObjectDeleteView):
queryset = VirtualDisk.objects.all()
class VirtualDiskBulkImportView(generic.BulkImportView):
queryset = VirtualDisk.objects.all()
model_form = forms.VirtualDiskImportForm
class VirtualDiskBulkEditView(generic.BulkEditView):
queryset = VirtualDisk.objects.all()
filterset = filtersets.VirtualDiskFilterSet
table = tables.VirtualDiskTable
form = forms.VirtualDiskBulkEditForm
class VirtualDiskBulkRenameView(generic.BulkRenameView):
queryset = VirtualDisk.objects.all()
form = forms.VirtualDiskBulkRenameForm
class VirtualDiskBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualDisk.objects.all()
filterset = filtersets.VirtualDiskFilterSet
table = tables.VirtualDiskTable
# #
# Bulk Device component creation # Bulk Device component creation
# #
@ -572,3 +650,17 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
def get_required_permission(self): def get_required_permission(self):
return f'virtualization.add_vminterface' return f'virtualization.add_vminterface'
class VirtualMachineBulkAddVirtualDiskView(generic.BulkComponentCreateView):
parent_model = VirtualMachine
parent_field = 'virtual_machine'
form = forms.VirtualDiskBulkCreateForm
queryset = VirtualDisk.objects.all()
model_form = forms.VirtualDiskForm
filterset = filtersets.VirtualMachineFilterSet
table = tables.VirtualMachineTable
default_return_url = 'virtualization:virtualmachine_list'
def get_required_permission(self):
return f'virtualization.add_virtualdisk'