Browse Source

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>
Arthur Hanson 2 years ago
parent
commit
549b0ea107
31 changed files with 804 additions and 63 deletions
  1. 1 0
      netbox/netbox/navigation/menu.py
  2. 59 0
      netbox/templates/virtualization/virtualdisk.html
  3. 30 8
      netbox/templates/virtualization/virtualmachine.html
  4. 19 5
      netbox/templates/virtualization/virtualmachine/base.html
  5. 14 0
      netbox/templates/virtualization/virtualmachine/virtual_disks.html
  6. 7 0
      netbox/templates/virtualization/virtualmachine_list.html
  7. 11 1
      netbox/virtualization/api/nested_serializers.py
  8. 20 3
      netbox/virtualization/api/serializers.py
  9. 1 0
      netbox/virtualization/api/urls.py
  10. 12 2
      netbox/virtualization/api/views.py
  11. 1 1
      netbox/virtualization/apps.py
  12. 28 1
      netbox/virtualization/filtersets.py
  13. 9 1
      netbox/virtualization/forms/bulk_create.py
  14. 34 0
      netbox/virtualization/forms/bulk_edit.py
  15. 15 0
      netbox/virtualization/forms/bulk_import.py
  16. 21 0
      netbox/virtualization/forms/filtersets.py
  17. 32 6
      netbox/virtualization/forms/model_forms.py
  18. 12 1
      netbox/virtualization/forms/object_create.py
  19. 6 0
      netbox/virtualization/graphql/schema.py
  20. 12 0
      netbox/virtualization/graphql/types.py
  21. 50 0
      netbox/virtualization/migrations/0038_virtualdisk.py
  22. 77 23
      netbox/virtualization/models/virtualmachines.py
  23. 10 0
      netbox/virtualization/search.py
  24. 16 0
      netbox/virtualization/signals.py
  25. 42 1
      netbox/virtualization/tables/virtualmachines.py
  26. 41 6
      netbox/virtualization/tests/test_api.py
  27. 44 1
      netbox/virtualization/tests/test_filtersets.py
  28. 25 0
      netbox/virtualization/tests/test_models.py
  29. 53 2
      netbox/virtualization/tests/test_views.py
  30. 9 0
      netbox/virtualization/urls.py
  31. 93 1
      netbox/virtualization/views.py

+ 1 - 0
netbox/netbox/navigation/menu.py

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

+ 59 - 0
netbox/templates/virtualization/virtualdisk.html

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

+ 30 - 8
netbox/templates/virtualization/virtualmachine.html

@@ -139,14 +139,16 @@
                         </td>
                     </tr>
                     <tr>
-                        <th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}</th>
-                        <td>
-                            {% if object.disk %}
-                                {{ object.disk }} {% trans "GB" context "Abbreviation for gigabyte" %}
-                            {% else %}
-                                {{ ''|placeholder }}
-                            {% endif %}
-                        </td>
+                      <th scope="row">
+                        <i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
+                      </th>
+                      <td>
+                        {% if object.disk %}
+                          {{ object.disk }} {% trans "GB" context "Abbreviation for gigabyte" %}
+                        {% else %}
+                          {{ ''|placeholder }}
+                        {% endif %}
+                      </td>
                     </tr>
                 </table>
             </div>
@@ -168,6 +170,26 @@
         {% plugin_right_page object %}
     </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="col col-md-12">
         {% plugin_full_width_page object %}

+ 19 - 5
netbox/templates/virtualization/virtualmachine/base.html

@@ -16,9 +16,23 @@
 {% endblock %}
 
 {% 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">
-      <i class="mdi mdi-plus-thick"></i> {% trans "Add Interfaces" %}
-    </a>
-  {% endif %}
+
+  <div class="dropdown">
+      <button id="add-components" type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+          <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
+      </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 %}

+ 14 - 0
netbox/templates/virtualization/virtualmachine/virtual_disks.html

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

+ 7 - 0
netbox/templates/virtualization/virtualmachine_list.html

@@ -15,6 +15,13 @@
             </button>
           </li>
         {% 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>
     </div>
   {% endif %}

+ 11 - 1
netbox/virtualization/api/nested_serializers.py

@@ -2,12 +2,13 @@ from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
 from netbox.api.serializers import WritableNestedSerializer
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import *
 
 __all__ = [
     'NestedClusterGroupSerializer',
     'NestedClusterSerializer',
     'NestedClusterTypeSerializer',
+    'NestedVirtualDiskSerializer',
     'NestedVMInterfaceSerializer',
     'NestedVirtualMachineSerializer',
 ]
@@ -72,3 +73,12 @@ class NestedVMInterfaceSerializer(WritableNestedSerializer):
     class Meta:
         model = VMInterface
         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']

+ 20 - 3
netbox/virtualization/api/serializers.py

@@ -14,7 +14,7 @@ from netbox.api.fields import ChoiceField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 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 *
 
 
@@ -84,6 +84,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
 
     # Counter fields
     interface_count = serializers.IntegerField(read_only=True)
+    virtual_disk_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = VirtualMachine
@@ -91,7 +92,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
             'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
             'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
-            'interface_count',
+            'interface_count', 'virtual_disk_count',
         ]
         validators = []
 
@@ -104,7 +105,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
             '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', 'config_context', 'created', 'last_updated',
-            'interface_count',
+            'interface_count', 'virtual_disk_count',
         ]
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
@@ -159,3 +160,19 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
                 })
 
         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',
+        ]

+ 1 - 0
netbox/virtualization/api/urls.py

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

+ 12 - 2
netbox/virtualization/api/views.py

@@ -6,7 +6,7 @@ from netbox.api.viewsets import NetBoxModelViewSet
 from utilities.query_functions import CollateAsChar
 from utilities.utils import count_related
 from virtualization import filtersets
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import *
 from . import serializers
 
 
@@ -55,7 +55,8 @@ class ClusterViewSet(NetBoxModelViewSet):
 
 class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
     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
 
@@ -92,3 +93,12 @@ class VMInterfaceViewSet(NetBoxModelViewSet):
     def get_bulk_destroy_queryset(self):
         # Ensure child interfaces are deleted prior to their parents
         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']

+ 1 - 1
netbox/virtualization/apps.py

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

+ 28 - 1
netbox/virtualization/filtersets.py

@@ -11,12 +11,13 @@ from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
 from .choices import *
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from .models import *
 
 __all__ = (
     'ClusterFilterSet',
     'ClusterGroupFilterSet',
     'ClusterTypeFilterSet',
+    'VirtualDiskFilterSet',
     'VirtualMachineFilterSet',
     'VMInterfaceFilterSet',
 )
@@ -305,3 +306,29 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
             Q(name__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)
+        )

+ 9 - 1
netbox/virtualization/forms/bulk_create.py

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

+ 34 - 0
netbox/virtualization/forms/bulk_edit.py

@@ -18,6 +18,8 @@ __all__ = (
     'ClusterBulkEditForm',
     'ClusterGroupBulkEditForm',
     'ClusterTypeBulkEditForm',
+    'VirtualDiskBulkEditForm',
+    'VirtualDiskBulkRenameForm',
     'VirtualMachineBulkEditForm',
     'VMInterfaceBulkEditForm',
     'VMInterfaceBulkRenameForm',
@@ -315,3 +317,35 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
         queryset=VMInterface.objects.all(),
         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()
+    )

+ 15 - 0
netbox/virtualization/forms/bulk_import.py

@@ -14,6 +14,7 @@ __all__ = (
     'ClusterImportForm',
     'ClusterGroupImportForm',
     'ClusterTypeImportForm',
+    'VirtualDiskImportForm',
     'VirtualMachineImportForm',
     'VMInterfaceImportForm',
 )
@@ -199,3 +200,17 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
             return True
         else:
             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'
+        )

+ 21 - 0
netbox/virtualization/forms/filtersets.py

@@ -16,6 +16,7 @@ __all__ = (
     'ClusterFilterForm',
     'ClusterGroupFilterForm',
     'ClusterTypeFilterForm',
+    'VirtualDiskFilterForm',
     'VirtualMachineFilterForm',
     'VMInterfaceFilterForm',
 )
@@ -221,3 +222,23 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
         label=_('L2VPN')
     )
     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)

+ 32 - 6
netbox/virtualization/forms/model_forms.py

@@ -22,6 +22,7 @@ __all__ = (
     'ClusterGroupForm',
     'ClusterRemoveDevicesForm',
     'ClusterTypeForm',
+    'VirtualDiskForm',
     'VirtualMachineForm',
     'VMInterfaceForm',
 )
@@ -240,6 +241,11 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
 
         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
             for family in [4, 6]:
                 ip_choices = [(None, '---------')]
@@ -276,12 +282,26 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
 
 
-class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
+#
+# Virtual machine components
+#
+
+class VMComponentForm(NetBoxModelForm):
     virtual_machine = DynamicModelChoiceField(
         label=_('Virtual machine'),
         queryset=VirtualMachine.objects.all(),
         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(
         queryset=VMInterface.objects.all(),
         required=False,
@@ -348,9 +368,15 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
             'mode': HTMXSelect(),
         }
 
-    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 VirtualDiskForm(VMComponentForm):
+
+    fieldsets = (
+        (_('Disk'), ('virtual_machine', 'name', 'size', 'description', 'tags')),
+    )
+
+    class Meta:
+        model = VirtualDisk
+        fields = [
+            'virtual_machine', 'name', 'size', 'description', 'tags',
+        ]

+ 12 - 1
netbox/virtualization/forms/object_create.py

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

+ 6 - 0
netbox/virtualization/graphql/schema.py

@@ -36,3 +36,9 @@ class VirtualizationQuery(graphene.ObjectType):
 
     def resolve_vm_interface_list(root, info, **kwargs):
         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)

+ 12 - 0
netbox/virtualization/graphql/types.py

@@ -8,6 +8,7 @@ __all__ = (
     'ClusterType',
     'ClusterGroupType',
     'ClusterTypeType',
+    'VirtualDiskType',
     'VirtualMachineType',
     'VMInterfaceType',
 )
@@ -54,3 +55,14 @@ class VMInterfaceType(IPAddressesMixin, ComponentObjectType):
 
     def resolve_mode(self, info):
         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

+ 50 - 0
netbox/virtualization/migrations/0038_virtualdisk.py

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

+ 77 - 23
netbox/virtualization/models/virtualmachines.py

@@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator
 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.urls import reverse
 from django.utils.translation import gettext_lazy as _
@@ -21,6 +21,7 @@ from utilities.tracking import TrackingModelMixin
 from virtualization.choices import *
 
 __all__ = (
+    'VirtualDisk',
     'VirtualMachine',
     'VMInterface',
 )
@@ -130,6 +131,10 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
         to_model='virtualization.VMInterface',
         to_field='virtual_machine'
     )
+    virtual_disk_count = CounterCacheField(
+        to_model='virtualization.VirtualDisk',
+        to_field='virtual_machine'
+    )
 
     objects = ConfigContextModelQuerySet.as_manager()
 
@@ -192,6 +197,17 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
                 ).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
         interfaces = self.interfaces.all() if self.pk else None
         for family in (4, 6):
@@ -236,11 +252,19 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
             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(
         to='virtualization.VirtualMachine',
         on_delete=models.CASCADE,
-        related_name='interfaces'
+        related_name='%(class)ss'
     )
     name = models.CharField(
         verbose_name=_('name'),
@@ -257,6 +281,42 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
         max_length=200,
         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(
         to='ipam.VLAN',
         on_delete=models.SET_NULL,
@@ -298,20 +358,10 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
         related_query_name='vminterface',
     )
 
-    class Meta:
-        ordering = ('virtual_machine', CollateAsChar('_name'))
-        constraints = (
-            models.UniqueConstraint(
-                fields=('virtual_machine', 'name'),
-                name='%(app_label)s_%(class)s_unique_virtual_machine_name'
-            ),
-        )
+    class Meta(ComponentModel.Meta):
         verbose_name = _('interface')
         verbose_name_plural = _('interfaces')
 
-    def __str__(self):
-        return self.name
-
     def get_absolute_url(self):
         return reverse('virtualization:vminterface', kwargs={'pk': self.pk})
 
@@ -359,15 +409,19 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
                 ).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
     def l2vpn_termination(self):
         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])

+ 10 - 0
netbox/virtualization/search.py

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

+ 16 - 0
netbox/virtualization/signals.py

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

+ 42 - 1
netbox/virtualization/tables/virtualmachines.py

@@ -4,10 +4,12 @@ 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 virtualization.models import VirtualMachine, VMInterface
+from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
 
 __all__ = (
+    'VirtualDiskTable',
     'VirtualMachineTable',
+    'VirtualMachineVirtualDiskTable',
     'VirtualMachineVMInterfaceTable',
     'VMInterfaceTable',
 )
@@ -84,6 +86,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
     interface_count = tables.Column(
         verbose_name=_('Interfaces')
     )
+    virtual_disk_count = tables.Column(
+        verbose_name=_('Virtual Disks')
+    )
     config_template = tables.Column(
         verbose_name=_('Config Template'),
         linkify=True
@@ -155,3 +160,39 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
         row_attrs = {
             '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')

+ 41 - 6
netbox/virtualization/tests/test_api.py

@@ -5,9 +5,9 @@ from dcim.choices import InterfaceModeChoices
 from dcim.models import Site
 from extras.models import ConfigTemplate
 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.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import *
 
 
 class AppTest(APITestCase):
@@ -256,10 +256,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
 
     @classmethod
     def setUpTestData(cls):
-
-        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')
+        virtualmachine = create_test_virtualmachine('Virtual Machine 1')
 
         interfaces = (
             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.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,
+            },
+        ]

+ 44 - 1
netbox/virtualization/tests/test_filtersets.py

@@ -6,7 +6,7 @@ from tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 from virtualization.choices import *
 from virtualization.filtersets import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import *
 
 
 class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -534,3 +534,46 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
     def test_description(self):
         params = {'description': ['foobar1', 'foobar2']}
         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)

+ 25 - 0
netbox/virtualization/tests/test_models.py

@@ -90,3 +90,28 @@ class VirtualMachineTestCase(TestCase):
         # Uniqueness validation for name should ignore case
         with self.assertRaises(ValidationError):
             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()

+ 53 - 2
netbox/virtualization/tests/test_views.py

@@ -5,9 +5,9 @@ from netaddr import EUI
 from dcim.choices import InterfaceModeChoices
 from dcim.models import DeviceRole, Platform, Site
 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.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import *
 
 
 class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@@ -403,3 +403,54 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         }
         self.client.post(self._get_url('bulk_delete'), data)
         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',
+        }

+ 9 - 0
netbox/virtualization/urls.py

@@ -48,4 +48,13 @@ urlpatterns = [
     path('interfaces/<int:pk>/', include(get_model_urls('virtualization', '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'),
 ]

+ 93 - 1
netbox/virtualization/views.py

@@ -22,7 +22,7 @@ from utilities.query_functions import CollateAsChar
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 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')
 class VirtualMachineConfigContextView(ObjectConfigContextView):
     queryset = VirtualMachine.objects.annotate_config_context_data()
@@ -556,6 +578,62 @@ class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
     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
 #
@@ -572,3 +650,17 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
 
     def get_required_permission(self):
         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'