Bladeren bron

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 jaren geleden
bovenliggende
commit
549b0ea107
31 gewijzigde bestanden met toevoegingen van 804 en 63 verwijderingen
  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=(
             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(

+ 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>
                         </td>
                     </tr>
                     </tr>
                     <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>
                     </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 %}

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

@@ -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">
-      <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 %}
 {% 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>
             </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 %}

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

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

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

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

+ 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.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']

+ 1 - 1
netbox/virtualization/apps.py

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

+ 28 - 1
netbox/virtualization/filtersets.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 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.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:
-        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 = _('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])

+ 10 - 0
netbox/virtualization/search.py

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

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

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

@@ -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):
-
-        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 = (
         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,
+            },
+        ]

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

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

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

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

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

+ 9 - 0
netbox/virtualization/urls.py

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

+ 93 - 1
netbox/virtualization/views.py

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