feat(virtualization): Add Virtual Machine Type model

Introduce `VirtualMachineType` to classify virtual machines and apply
default platform, vCPU, and memory values when creating a VM.

This adds the new model and its relationship to `VirtualMachine`, and
wires it through forms, filtersets, tables, views, the REST API,
GraphQL, navigation, search, documentation, and tests.

Explicit values set on a virtual machine continue to take precedence,
and changes to a type do not retroactively update existing VMs.
This commit is contained in:
Martin Hauser
2026-03-26 19:16:44 +01:00
parent a3a204f2fd
commit a9ff808d04
31 changed files with 1655 additions and 131 deletions

View File

@@ -74,6 +74,7 @@ These are considered the "core" application models which are used to model netwo
* [tenancy.Tenant](../models/tenancy/tenant.md) * [tenancy.Tenant](../models/tenancy/tenant.md)
* [virtualization.Cluster](../models/virtualization/cluster.md) * [virtualization.Cluster](../models/virtualization/cluster.md)
* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md) * [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
* [virtualization.VirtualMachineType](../models/virtualization/virtualmachinetype.md)
* [vpn.IKEPolicy](../models/vpn/ikepolicy.md) * [vpn.IKEPolicy](../models/vpn/ikepolicy.md)
* [vpn.IKEProposal](../models/vpn/ikeproposal.md) * [vpn.IKEProposal](../models/vpn/ikeproposal.md)
* [vpn.IPSecPolicy](../models/vpn/ipsecpolicy.md) * [vpn.IPSecPolicy](../models/vpn/ipsecpolicy.md)
@@ -93,6 +94,7 @@ Organization models are used to organize and classify primary models.
* [dcim.DeviceRole](../models/dcim/devicerole.md) * [dcim.DeviceRole](../models/dcim/devicerole.md)
* [dcim.Manufacturer](../models/dcim/manufacturer.md) * [dcim.Manufacturer](../models/dcim/manufacturer.md)
* [dcim.Platform](../models/dcim/platform.md) * [dcim.Platform](../models/dcim/platform.md)
* [dcim.RackGroup](../models/dcim/rackgroup.md)
* [dcim.RackRole](../models/dcim/rackrole.md) * [dcim.RackRole](../models/dcim/rackrole.md)
* [ipam.ASNRange](../models/ipam/asnrange.md) * [ipam.ASNRange](../models/ipam/asnrange.md)
* [ipam.RIR](../models/ipam/rir.md) * [ipam.RIR](../models/ipam/rir.md)

View File

@@ -5,27 +5,37 @@ Virtual machines, clusters, and standalone hypervisors can be modeled in NetBox
```mermaid ```mermaid
flowchart TD flowchart TD
ClusterGroup & ClusterType --> Cluster ClusterGroup & ClusterType --> Cluster
Cluster --> VirtualMachine VirtualMachineType --> VirtualMachine
Device --> VirtualMachine Device --> VirtualMachine
Cluster --> VirtualMachine
Platform --> VirtualMachine Platform --> VirtualMachine
VirtualMachine --> VMInterface VirtualMachine --> VMInterface
click Cluster "../../models/virtualization/cluster/" click Cluster "../../models/virtualization/cluster/"
click ClusterGroup "../../models/virtualization/clustergroup/" click ClusterGroup "../../models/virtualization/clustergroup/"
click ClusterType "../../models/virtualization/clustertype/" click ClusterType "../../models/virtualization/clustertype/"
click Device "../../models/dcim/device/" click VirtualMachineType "../../models/virtualization/virtualmachinetype/"
click Platform "../../models/dcim/platform/" click Device "../../models/dcim/device/"
click VirtualMachine "../../models/virtualization/virtualmachine/" click Platform "../../models/dcim/platform/"
click VMInterface "../../models/virtualization/vminterface/" click VirtualMachine "../../models/virtualization/virtualmachine/"
click VMInterface "../../models/virtualization/vminterface/"
``` ```
## Clusters ## Clusters
A cluster is one or more physical host devices on which virtual machines can run. Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional. A cluster is one or more physical host devices on which virtual machines can run.
Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional.
## Virtual Machine Types
A virtual machine type provides reusable classification for virtual machines and can define create-time defaults for platform, vCPUs, and memory. This is useful when multiple virtual machines share a common sizing or profile while still allowing per-instance overrides after creation.
## Virtual Machines ## Virtual Machines
A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes. For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may define its compute, memory, and storage resources as well. A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes.
For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may define its compute, memory, and storage resources as well. A VM can optionally be assigned a [virtual machine type](../models/virtualization/virtualmachinetype.md) to classify it and provide default values for selected attributes at creation time.
A VM can be placed in one of three ways: A VM can be placed in one of three ways:

View File

@@ -13,6 +13,12 @@ The virtual machine's configured name. Must be unique within its scoping context
- If assigned to a **cluster**: unique within the cluster and tenant. - If assigned to a **cluster**: unique within the cluster and tenant.
- If assigned to a **device** (no cluster): unique within the device and tenant. - If assigned to a **device** (no cluster): unique within the device and tenant.
### Type
The [virtual machine type](./virtualmachinetype.md) assigned to the VM. A type classifies a virtual machine and can provide default values for platform, vCPUs, and memory when the VM is created.
Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines.
### Role ### Role
The functional role assigned to the VM. The functional role assigned to the VM.
@@ -45,7 +51,7 @@ The location or host for this VM. At least one must be specified:
### Platform ### Platform
A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. If a virtual machine type defines a default platform, it will be applied when the VM is created unless an explicit platform is specified.
### Primary IPv4 & IPv6 Addresses ### Primary IPv4 & IPv6 Addresses
@@ -56,11 +62,11 @@ Each VM may designate one primary IPv4 address and/or one primary IPv6 address f
### vCPUs ### vCPUs
The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). If a virtual machine type defines a default vCPU allocation, it will be applied when the VM is created unless an explicit value is specified.
### Memory ### Memory
The amount of running memory provisioned, in megabytes. The amount of running memory provisioned, in megabytes. If a virtual machine type defines a default memory allocation, it will be applied when the VM is created unless an explicit value is specified.
### Disk ### Disk

View File

@@ -0,0 +1,27 @@
# Virtual Machine Types
A virtual machine type defines a reusable classification and default configuration for [virtual machines](./virtualmachine.md).
A type can optionally provide default values for a VM's [platform](../dcim/platform.md), vCPU allocation, and memory allocation. When a virtual machine is created with an assigned type, any unset values among these fields will inherit their defaults from the type. Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines.
## Fields
### Name
A unique human-friendly name.
### Slug
A unique URL-friendly identifier. (This value can be used for filtering.)
### Default Platform
If defined, virtual machines instantiated with this type will automatically inherit the selected platform when no explicit platform is provided.
### Default vCPUs
The default number of vCPUs to assign when creating a virtual machine from this type.
### Default Memory
The default amount of memory, in megabytes, to assign when creating a virtual machine from this type.

View File

@@ -285,9 +285,10 @@ nav:
- Cluster: 'models/virtualization/cluster.md' - Cluster: 'models/virtualization/cluster.md'
- ClusterGroup: 'models/virtualization/clustergroup.md' - ClusterGroup: 'models/virtualization/clustergroup.md'
- ClusterType: 'models/virtualization/clustertype.md' - ClusterType: 'models/virtualization/clustertype.md'
- VMInterface: 'models/virtualization/vminterface.md'
- VirtualDisk: 'models/virtualization/virtualdisk.md' - VirtualDisk: 'models/virtualization/virtualdisk.md'
- VirtualMachine: 'models/virtualization/virtualmachine.md' - VirtualMachine: 'models/virtualization/virtualmachine.md'
- VirtualMachineType: 'models/virtualization/virtualmachinetype.md'
- VMInterface: 'models/virtualization/vminterface.md'
- VPN: - VPN:
- IKEPolicy: 'models/vpn/ikepolicy.md' - IKEPolicy: 'models/vpn/ikepolicy.md'
- IKEProposal: 'models/vpn/ikeproposal.md' - IKEProposal: 'models/vpn/ikeproposal.md'

View File

@@ -1305,6 +1305,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'virtualdevicecontext', 'virtualdevicecontext',
'virtualdisk', 'virtualdisk',
'virtualmachine', 'virtualmachine',
'virtualmachinetype',
'vlan', 'vlan',
'vlangroup', 'vlangroup',
'vlantranslationpolicy', 'vlantranslationpolicy',

View File

@@ -270,6 +270,12 @@ VIRTUALIZATION_MENU = Menu(
get_model_item('virtualization', 'virtualdisk', _('Virtual Disks')), get_model_item('virtualization', 'virtualdisk', _('Virtual Disks')),
), ),
), ),
MenuGroup(
label=_('Virtual Machine Types'),
items=(
get_model_item('virtualization', 'virtualmachinetype', _('Virtual Machine Types')),
),
),
MenuGroup( MenuGroup(
label=_('Clusters'), label=_('Clusters'),
items=( items=(

View File

@@ -0,0 +1,10 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% block extra_controls %}
{% if perms.virtualization.add_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_add' %}?virtual_machine_type={{ object.pk }}" class="btn btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Virtual Machine" %}
</a>
{% endif %}
{% endblock extra_controls %}

View File

@@ -16,10 +16,10 @@ from netbox.api.fields import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer from tenancy.api.serializers_.tenants import TenantSerializer
from users.api.serializers_.mixins import OwnerMixin from users.api.serializers_.mixins import OwnerMixin
from virtualization.choices import *
from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
from ...choices import *
from ...models import VirtualDisk, VirtualMachine, VirtualMachineType, VMInterface
from .clusters import ClusterSerializer from .clusters import ClusterSerializer
from .nested import NestedVMInterfaceSerializer from .nested import NestedVMInterfaceSerializer
@@ -27,11 +27,29 @@ __all__ = (
'VMInterfaceSerializer', 'VMInterfaceSerializer',
'VirtualDiskSerializer', 'VirtualDiskSerializer',
'VirtualMachineSerializer', 'VirtualMachineSerializer',
'VirtualMachineTypeSerializer',
'VirtualMachineWithConfigContextSerializer', 'VirtualMachineWithConfigContextSerializer',
) )
class VirtualMachineTypeSerializer(PrimaryModelSerializer):
default_platform = PlatformSerializer(nested=True, required=False, allow_null=True)
# Counter fields
virtual_machine_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualMachineType
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'default_platform', 'default_vcpus',
'default_memory', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'virtual_machine_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
class VirtualMachineSerializer(PrimaryModelSerializer): class VirtualMachineSerializer(PrimaryModelSerializer):
virtual_machine_type = VirtualMachineTypeSerializer(nested=True, required=False, allow_null=True, default=None)
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
start_on_boot = ChoiceField(choices=VirtualMachineStartOnBootChoices, required=False) start_on_boot = ChoiceField(choices=VirtualMachineStartOnBootChoices, required=False)
site = SiteSerializer(nested=True, required=False, allow_null=True, default=None) site = SiteSerializer(nested=True, required=False, allow_null=True, default=None)
@@ -52,10 +70,10 @@ class VirtualMachineSerializer(PrimaryModelSerializer):
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', 'id', 'url', 'display_url', 'display', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot',
'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'site', 'cluster', 'device', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory',
'disk', 'description', 'owner', 'comments', 'config_template', 'local_context_data', 'tags', 'disk', 'description', 'serial', 'tenant', 'owner', 'comments', 'tags', 'local_context_data',
'custom_fields', 'created', 'last_updated', 'interface_count', 'virtual_disk_count', 'config_template', 'custom_fields', 'created', 'last_updated', 'interface_count', 'virtual_disk_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
@@ -65,10 +83,11 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class Meta(VirtualMachineSerializer.Meta): class Meta(VirtualMachineSerializer.Meta):
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', 'id', 'url', 'display_url', 'display', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot',
'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'site', 'cluster', 'device', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory',
'disk', 'description', 'owner', 'comments', 'config_template', 'local_context_data', 'tags', 'disk', 'description', 'serial', 'tenant', 'owner', 'comments', 'tags', 'local_context_data',
'custom_fields', 'config_context', 'created', 'last_updated', 'interface_count', 'virtual_disk_count', 'config_template', 'custom_fields', 'created', 'last_updated', 'interface_count', 'virtual_disk_count',
'config_context',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))

View File

@@ -10,6 +10,9 @@ router.register('cluster-types', views.ClusterTypeViewSet)
router.register('cluster-groups', views.ClusterGroupViewSet) router.register('cluster-groups', views.ClusterGroupViewSet)
router.register('clusters', views.ClusterViewSet) router.register('clusters', views.ClusterViewSet)
# Virtual machine types
router.register('virtual-machine-types', views.VirtualMachineTypeViewSet)
# VirtualMachines # VirtualMachines
router.register('virtual-machines', views.VirtualMachineViewSet) router.register('virtual-machines', views.VirtualMachineViewSet)
router.register('interfaces', views.VMInterfaceViewSet) router.register('interfaces', views.VMInterfaceViewSet)

View File

@@ -14,6 +14,7 @@ class VirtualizationRootView(APIRootView):
""" """
Virtualization API root view Virtualization API root view
""" """
def get_view_name(self): def get_view_name(self):
return 'Virtualization' return 'Virtualization'
@@ -22,6 +23,7 @@ class VirtualizationRootView(APIRootView):
# Clusters # Clusters
# #
class ClusterTypeViewSet(NetBoxModelViewSet): class ClusterTypeViewSet(NetBoxModelViewSet):
queryset = ClusterType.objects.all() queryset = ClusterType.objects.all()
serializer_class = serializers.ClusterTypeSerializer serializer_class = serializers.ClusterTypeSerializer
@@ -44,10 +46,22 @@ class ClusterViewSet(NetBoxModelViewSet):
filterset_class = filtersets.ClusterFilterSet filterset_class = filtersets.ClusterFilterSet
#
# Virtual machine types
#
class VirtualMachineTypeViewSet(NetBoxModelViewSet):
queryset = VirtualMachineType.objects.all()
serializer_class = serializers.VirtualMachineTypeSerializer
filterset_class = filtersets.VirtualMachineTypeFilterSet
# #
# Virtual machines # Virtual machines
# #
class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.all() queryset = VirtualMachine.objects.all()
filterset_class = filtersets.VirtualMachineFilterSet filterset_class = filtersets.VirtualMachineFilterSet

View File

@@ -9,10 +9,10 @@ class VirtualizationConfig(AppConfig):
from utilities.counters import connect_counters from utilities.counters import connect_counters
from . import search, signals # noqa: F401 from . import search, signals # noqa: F401
from .models import VirtualMachine from .models import VirtualMachine, VirtualMachineType
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())
# Register counters # Register counters
connect_counters(VirtualMachine) connect_counters(VirtualMachine, VirtualMachineType)

View File

@@ -26,6 +26,7 @@ __all__ = (
'VMInterfaceFilterSet', 'VMInterfaceFilterSet',
'VirtualDiskFilterSet', 'VirtualDiskFilterSet',
'VirtualMachineFilterSet', 'VirtualMachineFilterSet',
'VirtualMachineTypeFilterSet',
) )
@@ -91,6 +92,45 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ScopedFilterSet,
) )
@register_filterset
class VirtualMachineTypeFilterSet(PrimaryModelFilterSet):
default_platform_id = TreeNodeMultipleChoiceFilter(
queryset=Platform.objects.all(),
field_name='default_platform',
lookup_expr='in',
label=_('Default platform (ID)'),
)
default_platform = TreeNodeMultipleChoiceFilter(
queryset=Platform.objects.all(),
field_name='default_platform',
to_field_name='slug',
lookup_expr='in',
label=_('Default platform (slug)'),
)
class Meta:
model = VirtualMachineType
fields = (
'id',
'name',
'slug',
'default_vcpus',
'default_memory',
'description',
'virtual_machine_count',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
@register_filterset @register_filterset
class VirtualMachineFilterSet( class VirtualMachineFilterSet(
PrimaryModelFilterSet, PrimaryModelFilterSet,
@@ -99,6 +139,18 @@ class VirtualMachineFilterSet(
LocalConfigContextFilterSet, LocalConfigContextFilterSet,
PrimaryIPFilterSet, PrimaryIPFilterSet,
): ):
virtual_machine_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualMachineType.objects.all(),
distinct=False,
label=_('Virtual machine type (ID)'),
)
virtual_machine_type = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine_type__slug',
queryset=VirtualMachineType.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Virtual machine type (slug)'),
)
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=VirtualMachineStatusChoices, choices=VirtualMachineStatusChoices,
distinct=False, distinct=False,

View File

@@ -14,8 +14,9 @@ from utilities.forms import BulkRenameForm, add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect from utilities.forms.widgets import BulkEditNullBooleanSelect
from virtualization.choices import *
from virtualization.models import * from ..choices import *
from ..models import *
__all__ = ( __all__ = (
'ClusterBulkEditForm', 'ClusterBulkEditForm',
@@ -26,6 +27,7 @@ __all__ = (
'VirtualDiskBulkEditForm', 'VirtualDiskBulkEditForm',
'VirtualDiskBulkRenameForm', 'VirtualDiskBulkRenameForm',
'VirtualMachineBulkEditForm', 'VirtualMachineBulkEditForm',
'VirtualMachineTypeBulkEditForm',
) )
@@ -78,7 +80,37 @@ class ClusterBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
) )
class VirtualMachineTypeBulkEditForm(PrimaryModelBulkEditForm):
default_platform = DynamicModelChoiceField(
label=_('Default platform'),
queryset=Platform.objects.all(),
required=False
)
default_vcpus = forms.IntegerField(
label=_('Default vCPUs'),
required=False,
)
default_memory = forms.IntegerField(
label=_('Default Memory (MB)'),
required=False,
)
model = VirtualMachineType
fieldsets = (
FieldSet('description', name=_('Virtual Machine Type')),
FieldSet('default_platform', 'default_vcpus', 'default_memory', name=_('Defaults')),
)
nullable_fields = (
'default_platform', 'default_vcpus', 'default_memory', 'description', 'comments',
)
class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm): class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
virtual_machine_type = DynamicModelChoiceField(
label=_('Virtual machine type'),
queryset=VirtualMachineType.objects.all(),
required=False
)
status = forms.ChoiceField( status = forms.ChoiceField(
label=_('Status'), label=_('Status'),
choices=add_blank_choice(VirtualMachineStatusChoices), choices=add_blank_choice(VirtualMachineStatusChoices),
@@ -152,13 +184,14 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
model = VirtualMachine model = VirtualMachine
fieldsets = ( fieldsets = (
FieldSet('status', 'start_on_boot', 'role', 'tenant', 'platform', 'description'), FieldSet('virtual_machine_type', 'status', 'start_on_boot', 'role', 'tenant', 'platform', 'description'),
FieldSet('site', 'cluster', 'device', name=_('Placement')), FieldSet('site', 'cluster', 'device', name=_('Placement')),
FieldSet('vcpus', 'memory', 'disk', name=_('Resources')), FieldSet('vcpus', 'memory', 'disk', name=_('Resources')),
FieldSet('config_template', name=_('Configuration')), FieldSet('config_template', name=_('Configuration')),
) )
nullable_fields = ( nullable_fields = (
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments', 'virtual_machine_type', 'role', 'site', 'cluster', 'device', 'platform', 'vcpus', 'memory', 'disk', 'tenant',
'description', 'comments',
) )

View File

@@ -10,8 +10,9 @@ from ipam.models import VLAN, VRF, VLANGroup
from netbox.forms import NetBoxModelImportForm, OrganizationalModelImportForm, OwnerCSVMixin, PrimaryModelImportForm from netbox.forms import NetBoxModelImportForm, OrganizationalModelImportForm, OwnerCSVMixin, PrimaryModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
from virtualization.choices import *
from virtualization.models import * from ..choices import *
from ..models import *
__all__ = ( __all__ = (
'ClusterGroupImportForm', 'ClusterGroupImportForm',
@@ -20,6 +21,7 @@ __all__ = (
'VMInterfaceImportForm', 'VMInterfaceImportForm',
'VirtualDiskImportForm', 'VirtualDiskImportForm',
'VirtualMachineImportForm', 'VirtualMachineImportForm',
'VirtualMachineTypeImportForm',
) )
@@ -82,7 +84,31 @@ class ClusterImportForm(ScopedImportForm, PrimaryModelImportForm):
} }
class VirtualMachineTypeImportForm(PrimaryModelImportForm):
default_platform = CSVModelChoiceField(
label=_('Default platform'),
queryset=Platform.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned default platform'),
)
class Meta:
model = VirtualMachineType
fields = (
'name', 'slug', 'default_platform', 'default_vcpus', 'default_memory', 'description',
'owner', 'comments', 'tags',
)
class VirtualMachineImportForm(PrimaryModelImportForm): class VirtualMachineImportForm(PrimaryModelImportForm):
virtual_machine_type = CSVModelChoiceField(
label=_('Virtual machine type'),
queryset=VirtualMachineType.objects.all(),
to_field_name='name',
required=False,
help_text=_('Optional virtual machine type'),
)
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'), label=_('Status'),
choices=VirtualMachineStatusChoices, choices=VirtualMachineStatusChoices,
@@ -149,8 +175,9 @@ class VirtualMachineImportForm(PrimaryModelImportForm):
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = ( fields = (
'name', 'status', 'start_on_boot', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device',
'memory', 'disk', 'description', 'serial', 'config_template', 'comments', 'owner', 'tags', 'platform', 'vcpus', 'memory', 'disk', 'description', 'serial',
'tenant', 'owner', 'comments', 'tags', 'config_template',
) )

View File

@@ -12,10 +12,11 @@ from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from virtualization.choices import *
from virtualization.models import *
from vpn.models import L2VPN from vpn.models import L2VPN
from ..choices import *
from ..models import *
__all__ = ( __all__ = (
'ClusterFilterForm', 'ClusterFilterForm',
'ClusterGroupFilterForm', 'ClusterGroupFilterForm',
@@ -23,6 +24,7 @@ __all__ = (
'VMInterfaceFilterForm', 'VMInterfaceFilterForm',
'VirtualDiskFilterForm', 'VirtualDiskFilterForm',
'VirtualMachineFilterForm', 'VirtualMachineFilterForm',
'VirtualMachineTypeFilterForm',
) )
@@ -100,6 +102,43 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelF
tag = TagFilterField(model) tag = TagFilterField(model)
class VirtualMachineTypeFilterForm(PrimaryModelFilterSetForm):
model = VirtualMachineType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'default_platform_id', 'default_vcpus', 'default_memory', 'virtual_machine_count',
name=_('Attributes'),
),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q')
default_platform_id = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False,
label=_('Default platform'),
)
default_vcpus = forms.DecimalField(
label=_('Default vCPUs'),
required=False,
)
default_memory = forms.IntegerField(
label=_('Default memory (MB)'),
required=False,
min_value=0,
)
virtual_machine_count = forms.IntegerField(
label=_('Virtual machine count'),
required=False,
min_value=0,
)
tag = TagFilterField(model)
class VirtualMachineFilterForm( class VirtualMachineFilterForm(
LocalConfigContextFilterForm, LocalConfigContextFilterForm,
TenancyFilterForm, TenancyFilterForm,
@@ -112,13 +151,20 @@ class VirtualMachineFilterForm(
FieldSet('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id', name=_('Cluster')), FieldSet('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id', name=_('Cluster')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet( FieldSet(
'status', 'start_on_boot', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'config_template_id', 'virtual_machine_type_id', 'status', 'start_on_boot', 'role_id', 'platform_id', 'mac_address',
'local_context_data', 'serial', name=_('Attributes') 'has_primary_ip', 'config_template_id', 'local_context_data', 'serial',
name=_('Attributes')
), ),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
virtual_machine_type_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachineType.objects.all(),
required=False,
null_option='None',
label=_('Virtual machine type'),
)
cluster_group_id = DynamicModelMultipleChoiceField( cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.all(),
required=False, required=False,

View File

@@ -14,10 +14,11 @@ from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelF
from netbox.forms.mixins import OwnerMixin from netbox.forms.mixins import OwnerMixin
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import HTMXSelect from utilities.forms.widgets import HTMXSelect
from virtualization.models import *
from ..models import *
__all__ = ( __all__ = (
'ClusterAddDevicesForm', 'ClusterAddDevicesForm',
@@ -28,6 +29,7 @@ __all__ = (
'VMInterfaceForm', 'VMInterfaceForm',
'VirtualDiskForm', 'VirtualDiskForm',
'VirtualMachineForm', 'VirtualMachineForm',
'VirtualMachineTypeForm',
) )
@@ -167,7 +169,35 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
) )
class VirtualMachineTypeForm(PrimaryModelForm):
slug = SlugField()
default_platform = DynamicModelChoiceField(
label=_('Default platform'),
queryset=Platform.objects.all(),
required=False,
selector=True,
)
fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('Virtual Machine Type')),
FieldSet('default_platform', 'default_vcpus', 'default_memory', name=_('Defaults')),
)
class Meta:
model = VirtualMachineType
fields = (
'name', 'slug', 'default_platform', 'default_vcpus', 'default_memory', 'description',
'owner', 'comments', 'tags',
)
class VirtualMachineForm(TenancyForm, PrimaryModelForm): class VirtualMachineForm(TenancyForm, PrimaryModelForm):
virtual_machine_type = DynamicModelChoiceField(
label=_('Type'),
queryset=VirtualMachineType.objects.all(),
required=False,
selector=True,
)
site = DynamicModelChoiceField( site = DynamicModelChoiceField(
label=_('Site'), label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
@@ -226,7 +256,10 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
) )
fieldsets = ( fieldsets = (
FieldSet('name', 'role', 'status', 'start_on_boot', 'description', 'serial', 'tags', name=_('Virtual Machine')), FieldSet(
'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'description', 'serial', 'tags',
name=_('Virtual Machine')
),
FieldSet('site', 'cluster', 'device', name=_('Placement')), FieldSet('site', 'cluster', 'device', name=_('Placement')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('platform', 'primary_ip4', 'primary_ip6', 'config_template', name=_('Management')), FieldSet('platform', 'primary_ip4', 'primary_ip6', 'config_template', name=_('Management')),
@@ -237,9 +270,9 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = [ fields = [
'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device',
'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial', 'owner', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial',
'comments', 'tags', 'local_context_data', 'config_template', 'tenant_group', 'tenant', 'owner', 'comments', 'tags', 'local_context_data', 'config_template',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry import strawberry
import strawberry_django import strawberry_django
from strawberry.scalars import ID from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, StrFilterLookup
from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin
from extras.graphql.filter_mixins import ConfigContextFilterMixin from extras.graphql.filter_mixins import ConfigContextFilterMixin
@@ -34,6 +34,7 @@ __all__ = (
'VMInterfaceFilter', 'VMInterfaceFilter',
'VirtualDiskFilter', 'VirtualDiskFilter',
'VirtualMachineFilter', 'VirtualMachineFilter',
'VirtualMachineTypeFilter',
) )
@@ -68,6 +69,24 @@ class ClusterTypeFilter(OrganizationalModelFilter):
pass pass
@strawberry_django.filter_type(models.VirtualMachineType, lookups=True)
class VirtualMachineTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilter):
default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
default_platform_id: ID | None = strawberry_django.filter_field()
default_vcpus: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
default_memory: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
instances: Annotated['VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
strawberry_django.filter_field()
)
virtual_machine_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VirtualMachine, lookups=True) @strawberry_django.filter_type(models.VirtualMachine, lookups=True)
class VirtualMachineFilter( class VirtualMachineFilter(
ContactFilterMixin, ContactFilterMixin,
@@ -78,6 +97,9 @@ class VirtualMachineFilter(
PrimaryModelFilter, PrimaryModelFilter,
): ):
name: StrFilterLookup[str] | None = strawberry_django.filter_field() name: StrFilterLookup[str] | None = strawberry_django.filter_field()
virtual_machine_type: (
Annotated['VirtualMachineTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None
) = strawberry_django.filter_field()
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field() site_id: ID | None = strawberry_django.filter_field()
cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
@@ -92,9 +114,7 @@ class VirtualMachineFilter(
platform_id: ID | None = strawberry_django.filter_field() platform_id: ID | None = strawberry_django.filter_field()
status: ( status: (
BaseFilterLookup[Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')]] | None BaseFilterLookup[Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')]] | None
) = ( ) = strawberry_django.filter_field()
strawberry_django.filter_field()
)
role: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( role: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@@ -117,8 +137,6 @@ class VirtualMachineFilter(
strawberry_django.filter_field() strawberry_django.filter_field()
) )
serial: StrFilterLookup[str] | None = strawberry_django.filter_field() serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
interface_count: FilterLookup[int] | None = strawberry_django.filter_field()
virtual_disk_count: FilterLookup[int] | None = strawberry_django.filter_field()
interfaces: Annotated['VMInterfaceFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( interfaces: Annotated['VMInterfaceFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@@ -129,10 +147,11 @@ class VirtualMachineFilter(
strawberry_django.filter_field() strawberry_django.filter_field()
) )
start_on_boot: ( start_on_boot: (
BaseFilterLookup[Annotated['VirtualMachineStartOnBootEnum', strawberry.lazy('virtualization.graphql.enums')] BaseFilterLookup[Annotated['VirtualMachineStartOnBootEnum', strawberry.lazy('virtualization.graphql.enums')]]
] | None) = ( | None
strawberry_django.filter_field() ) = strawberry_django.filter_field()
) interface_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
virtual_disk_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VMInterface, lookups=True) @strawberry_django.filter_type(models.VMInterface, lookups=True)

View File

@@ -4,7 +4,7 @@ import strawberry_django
from .types import * from .types import *
@strawberry.type(name="Query") @strawberry.type(name='Query')
class VirtualizationQuery: class VirtualizationQuery:
cluster: ClusterType = strawberry_django.field() cluster: ClusterType = strawberry_django.field()
cluster_list: list[ClusterType] = strawberry_django.field() cluster_list: list[ClusterType] = strawberry_django.field()
@@ -15,6 +15,9 @@ class VirtualizationQuery:
cluster_type: ClusterTypeType = strawberry_django.field() cluster_type: ClusterTypeType = strawberry_django.field()
cluster_type_list: list[ClusterTypeType] = strawberry_django.field() cluster_type_list: list[ClusterTypeType] = strawberry_django.field()
virtual_machine_type: VirtualMachineTypeType = strawberry_django.field()
virtual_machine_type_list: list[VirtualMachineTypeType] = strawberry_django.field()
virtual_machine: VirtualMachineType = strawberry_django.field() virtual_machine: VirtualMachineType = strawberry_django.field()
virtual_machine_list: list[VirtualMachineType] = strawberry_django.field() virtual_machine_list: list[VirtualMachineType] = strawberry_django.field()

View File

@@ -34,6 +34,7 @@ __all__ = (
'VMInterfaceType', 'VMInterfaceType',
'VirtualDiskType', 'VirtualDiskType',
'VirtualMachineType', 'VirtualMachineType',
'VirtualMachineTypeType',
) )
@@ -91,6 +92,19 @@ class ClusterTypeType(OrganizationalObjectType):
clusters: list[ClusterType] clusters: list[ClusterType]
@strawberry_django.type(
models.VirtualMachineType,
fields='__all__',
filters=VirtualMachineTypeFilter,
pagination=True
)
class VirtualMachineTypeType(PrimaryObjectType):
virtual_machine_count: BigInt
default_platform: Annotated['PlatformType', strawberry.lazy('dcim.graphql.types')] | None
instances: list[Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
models.VirtualMachine, models.VirtualMachine,
fields='__all__', fields='__all__',
@@ -101,6 +115,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, PrimaryObjectType):
interface_count: BigInt interface_count: BigInt
virtual_disk_count: BigInt virtual_disk_count: BigInt
interface_count: BigInt interface_count: BigInt
virtual_machine_type: Annotated['VirtualMachineTypeType', strawberry.lazy('virtualization.graphql.types')] | None
config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None
cluster: Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')] | None cluster: Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')] | None

View File

@@ -0,0 +1,106 @@
from decimal import Decimal
import django.core.validators
import django.db.models.deletion
import django.db.models.functions.text
import taggit.managers
from django.db import migrations, models
import netbox.models.deletion
import utilities.fields
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0053_virtualmachine_standalone_device_assignment'),
]
operations = [
migrations.CreateModel(
name='VirtualMachineType',
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),
),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)),
(
'default_vcpus',
models.DecimalField(
blank=True,
decimal_places=2,
max_digits=6,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('0.01'))],
),
),
('default_memory', models.PositiveIntegerField(blank=True, null=True)),
(
'virtual_machine_count',
utilities.fields.CounterCacheField(
default=0,
editable=False,
to_field='virtual_machine_type',
to_model='virtualization.VirtualMachine',
),
),
(
'default_platform',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.platform',
),
),
(
'owner',
models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'virtual machine type',
'verbose_name_plural': 'virtual machine types',
'ordering': ('name',),
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
migrations.AddField(
model_name='virtualmachine',
name='virtual_machine_type',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='instances',
to='virtualization.virtualmachinetype',
),
),
migrations.AddConstraint(
model_name='virtualmachinetype',
constraint=models.UniqueConstraint(
django.db.models.functions.text.Lower('name'),
name='virtualization_virtualmachinetype_unique_name',
violation_error_message='Virtual machine type name must be unique.',
),
),
migrations.AddConstraint(
model_name='virtualmachinetype',
constraint=models.UniqueConstraint(
fields=('slug',),
name='virtualization_virtualmachinetype_unique_slug',
violation_error_message='Virtual machine type slug must be unique.',
),
),
]

View File

@@ -20,16 +20,88 @@ from utilities.fields import CounterCacheField, NaturalOrderingField
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.tracking import TrackingModelMixin from utilities.tracking import TrackingModelMixin
from virtualization.choices import *
from ..choices import *
__all__ = ( __all__ = (
'VMInterface', 'VMInterface',
'VirtualDisk', 'VirtualDisk',
'VirtualMachine', 'VirtualMachine',
'VirtualMachineType',
) )
class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel): class VirtualMachineType(ImageAttachmentsMixin, PrimaryModel):
"""
A type defining default attributes (platform, vCPUs, memory, etc.) for virtual machines.
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100,
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
)
default_platform = models.ForeignKey(
to='dcim.Platform',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
verbose_name=_('default platform'),
)
default_vcpus = models.DecimalField(
verbose_name=_('default vCPUs'),
max_digits=6,
decimal_places=2,
blank=True,
null=True,
validators=(MinValueValidator(decimal.Decimal('0.01')),),
)
default_memory = models.PositiveIntegerField(
verbose_name=_('default memory (MB)'),
blank=True,
null=True,
)
# Counter fields
virtual_machine_count = CounterCacheField(
to_model='virtualization.VirtualMachine',
to_field='virtual_machine_type',
)
clone_fields = (
'default_platform',
'default_vcpus',
'default_memory',
)
class Meta:
ordering = ('name',)
constraints = (
models.UniqueConstraint(
Lower('name'),
name='%(app_label)s_%(class)s_unique_name',
violation_error_message=_('Virtual machine type name must be unique.'),
),
models.UniqueConstraint(
fields=('slug',),
name='%(app_label)s_%(class)s_unique_slug',
violation_error_message=_('Virtual machine type slug must be unique.'),
),
)
verbose_name = _('virtual machine type')
verbose_name_plural = _('virtual machine types')
def __str__(self):
return self.name
class VirtualMachine(
ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, TrackingModelMixin, PrimaryModel
):
""" """
A virtual machine which runs on a Cluster or a standalone Device. A virtual machine which runs on a Cluster or a standalone Device.
@@ -42,6 +114,15 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
When a Cluster or Device is set, the Site is automatically inherited if not explicitly provided. When a Cluster or Device is set, the Site is automatically inherited if not explicitly provided.
If a Device belongs to a Cluster, the Cluster must also be specified on the VM. If a Device belongs to a Cluster, the Cluster must also be specified on the VM.
""" """
virtual_machine_type = models.ForeignKey(
to='virtualization.VirtualMachineType',
on_delete=models.PROTECT,
related_name='instances',
verbose_name=_('type'),
blank=True,
null=True,
)
site = models.ForeignKey( site = models.ForeignKey(
to='dcim.Site', to='dcim.Site',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@@ -162,7 +243,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
objects = ConfigContextModelQuerySet.as_manager() objects = ConfigContextModelQuerySet.as_manager()
clone_fields = ( clone_fields = (
'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', 'virtual_machine_type', 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory',
'disk',
) )
class Meta: class Meta:
@@ -282,8 +364,29 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
elif self.device and self.device.site: elif self.device and self.device.site:
self.site = self.device.site self.site = self.device.site
if self._state.adding:
self.apply_type_defaults()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def apply_type_defaults(self):
"""
Populate any empty fields with defaults from the assigned VirtualMachineType.
"""
if not self.virtual_machine_type_id:
return
defaults = {
'platform_id': 'default_platform_id',
'vcpus': 'default_vcpus',
'memory': 'default_memory',
}
for field, default_field in defaults.items():
if getattr(self, field) is None:
default_value = getattr(self.virtual_machine_type, default_field)
if default_value is not None:
setattr(self, field, default_value)
def get_status_color(self): def get_status_color(self):
return VirtualMachineStatusChoices.colors.get(self.status) return VirtualMachineStatusChoices.colors.get(self.status)

View File

@@ -38,6 +38,18 @@ class ClusterTypeIndex(SearchIndex):
display_attrs = ('description',) display_attrs = ('description',)
@register_search
class VirtualMachineTypeIndex(SearchIndex):
model = models.VirtualMachineType
fields = (
('name', 100),
('slug', 110),
('description', 500),
('comments', 5000),
)
display_attrs = ('default_platform', 'default_vcpus', 'default_memory', 'description')
@register_search @register_search
class VirtualMachineIndex(SearchIndex): class VirtualMachineIndex(SearchIndex):
model = models.VirtualMachine model = models.VirtualMachine

View File

@@ -5,28 +5,64 @@ from dcim.tables.devices import BaseInterfaceTable
from netbox.tables import NetBoxTable, PrimaryModelTable, columns from netbox.tables import NetBoxTable, PrimaryModelTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from utilities.templatetags.helpers import humanize_disk_megabytes from utilities.templatetags.helpers import humanize_disk_megabytes
from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
from ..models import VirtualDisk, VirtualMachine, VirtualMachineType, VMInterface
from .template_code import * from .template_code import *
__all__ = ( __all__ = (
'VMInterfaceTable', 'VMInterfaceTable',
'VirtualDiskTable', 'VirtualDiskTable',
'VirtualMachineTable', 'VirtualMachineTable',
'VirtualMachineTypeTable',
'VirtualMachineVMInterfaceTable', 'VirtualMachineVMInterfaceTable',
'VirtualMachineVirtualDiskTable', 'VirtualMachineVirtualDiskTable',
) )
#
# Virtual machine types
#
class VirtualMachineTypeTable(PrimaryModelTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True,
)
default_platform = tables.Column(
verbose_name=_('Default platform'),
linkify=True,
)
virtual_machine_count = tables.Column(
verbose_name=_('VM count')
)
tags = columns.TagColumn(
url_name='virtualization:virtualmachinetype_list'
)
class Meta(PrimaryModelTable.Meta):
model = VirtualMachineType
fields = (
'pk', 'id', 'name', 'slug', 'default_platform', 'default_vcpus', 'default_memory', 'virtual_machine_count',
'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'default_platform', 'default_vcpus', 'default_memory', 'virtual_machine_count', 'description',
)
# #
# Virtual machines # Virtual machines
# #
class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable): class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
name = tables.Column( name = tables.Column(
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True linkify=True
) )
virtual_machine_type = tables.Column(
verbose_name=_('Type'),
linkify=True,
)
status = columns.ChoiceFieldColumn( status = columns.ChoiceFieldColumn(
verbose_name=_('Status'), verbose_name=_('Status'),
) )
@@ -85,12 +121,13 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModel
class Meta(PrimaryModelTable.Meta): class Meta(PrimaryModelTable.Meta):
model = VirtualMachine model = VirtualMachine
fields = ( fields = (
'pk', 'id', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', 'role', 'tenant', 'pk', 'id', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device',
'tenant_group', 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'platform', 'primary_ip4', 'primary_ip6', 'primary_ip', 'vcpus', 'memory', 'disk', 'description', 'serial',
'comments', 'config_template', 'serial', 'contacts', 'tags', 'created', 'last_updated', 'tenant_group', 'tenant', 'contacts', 'comments', 'tags', 'config_template', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', 'pk', 'name', 'virtual_machine_type', 'role', 'status', 'site', 'cluster', 'tenant',
'vcpus', 'memory', 'disk', 'primary_ip',
) )
def render_disk(self, value): def render_disk(self, value):

View File

@@ -7,7 +7,7 @@ from rest_framework import status
from core.models import ObjectType from core.models import ObjectType
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import Site from dcim.models import Platform, Site
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from extras.models import ConfigTemplate, CustomField from extras.models import ConfigTemplate, CustomField
from ipam.choices import VLANQinQRoleChoices from ipam.choices import VLANQinQRoleChoices
@@ -167,54 +167,170 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
] ]
class VirtualMachineTypeTest(APIViewTestCases.APIViewTestCase):
model = VirtualMachineType
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
user_permissions = ('dcim.view_platform', 'virtualization.view_virtualmachine')
@classmethod
def setUpTestData(cls):
cls.platforms = (
Platform.objects.create(name='Platform 1', slug='platform-1'),
Platform.objects.create(name='Platform 2', slug='platform-2'),
Platform.objects.create(name='Platform 3', slug='platform-3'),
)
cls.virtual_machine_types = (
VirtualMachineType.objects.create(
name='Virtual Machine Type 1',
slug='virtual-machine-type-1',
default_platform=cls.platforms[0],
default_vcpus=1,
default_memory=1024,
),
VirtualMachineType.objects.create(
name='Virtual Machine Type 2',
slug='virtual-machine-type-2',
default_platform=cls.platforms[1],
default_vcpus=2,
default_memory=2048,
),
VirtualMachineType.objects.create(
name='Virtual Machine Type 3',
slug='virtual-machine-type-3',
default_platform=cls.platforms[2],
default_vcpus=4,
default_memory=4096,
),
)
cls.create_data = [
{
'name': 'Virtual Machine Type 4',
'slug': 'virtual-machine-type-4',
'default_platform': cls.platforms[0].pk,
'default_vcpus': 1,
'default_memory': 1024,
},
{
'name': 'Virtual Machine Type 5',
'slug': 'virtual-machine-type-5',
'default_platform': cls.platforms[1].pk,
'default_vcpus': 2,
'default_memory': 2048,
},
{
'name': 'Virtual Machine Type 6',
'slug': 'virtual-machine-type-6',
'default_platform': cls.platforms[2].pk,
'default_vcpus': 4,
'default_memory': 4096,
},
]
cls.bulk_update_data = {
'default_platform': cls.platforms[2].pk,
'default_vcpus': 8,
'default_memory': 8192,
'description': 'New description',
}
def test_filter_by_default_platform(self):
self.add_permissions('virtualization.view_virtualmachinetype')
response = self.client.get(
f'{self._get_list_url()}?default_platform_id={self.platforms[0].pk}',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 1)
response = self.client.get(
f'{self._get_list_url()}?default_platform={self.platforms[0].slug}',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 1)
class VirtualMachineTest(APIViewTestCases.APIViewTestCase): class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
model = VirtualMachine model = VirtualMachine
brief_fields = ['description', 'display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'status': 'staged', 'status': 'staged',
} }
user_permissions = ('dcim.view_platform', 'virtualization.view_virtualmachinetype')
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1') clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
sites = ( cls.sites = (
Site(name='Site 1', slug='site-1'), Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'), Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'), Site(name='Site 3', slug='site-3'),
) )
Site.objects.bulk_create(sites) Site.objects.bulk_create(cls.sites)
clusters = ( cls.clusters = (
Cluster(name='Cluster 1', type=clustertype, scope=sites[0], group=clustergroup), Cluster(name='Cluster 1', type=clustertype, scope=cls.sites[0], group=clustergroup),
Cluster(name='Cluster 2', type=clustertype, scope=sites[1], group=clustergroup), Cluster(name='Cluster 2', type=clustertype, scope=cls.sites[1], group=clustergroup),
Cluster(name='Cluster 3', type=clustertype), Cluster(name='Cluster 3', type=clustertype),
) )
for cluster in clusters: for cluster in cls.clusters:
cluster.save() cluster.save()
device1 = create_test_device('device1', site=sites[0], cluster=clusters[0]) cls.devices = (
device2 = create_test_device('device2', site=sites[1], cluster=clusters[1]) create_test_device('device1', site=cls.sites[0], cluster=cls.clusters[0]),
create_test_device('device2', site=cls.sites[1], cluster=cls.clusters[1]),
)
cls.platforms = (
Platform.objects.create(name='Platform 1', slug='platform-1'),
Platform.objects.create(name='Platform 2', slug='platform-2'),
Platform.objects.create(name='Platform 3', slug='platform-3'),
)
cls.vm_types = (
VirtualMachineType.objects.create(
name='Virtual Machine Type 1',
slug='virtual-machine-type-1',
default_platform=cls.platforms[0],
default_vcpus=2,
default_memory=4096,
),
VirtualMachineType.objects.create(
name='Virtual Machine Type 2',
slug='virtual-machine-type-2',
default_platform=cls.platforms[1],
default_vcpus=4,
default_memory=8192,
),
)
virtual_machines = ( virtual_machines = (
VirtualMachine( VirtualMachine(
name='Virtual Machine 1', name='Virtual Machine 1',
site=sites[0], virtual_machine_type=cls.vm_types[0],
cluster=clusters[0], site=cls.sites[0],
device=device1, cluster=cls.clusters[0],
device=cls.devices[0],
platform=cls.platforms[0],
vcpus=2,
memory=4096,
local_context_data={'A': 1}, local_context_data={'A': 1},
), ),
VirtualMachine( VirtualMachine(
name='Virtual Machine 2', name='Virtual Machine 2',
site=sites[0], site=cls.sites[0],
cluster=clusters[0], cluster=cls.clusters[0],
local_context_data={'B': 2 local_context_data={'B': 2},
}), ),
VirtualMachine( VirtualMachine(
name='Virtual Machine 3', name='Virtual Machine 3',
site=sites[0], site=cls.sites[0],
cluster=clusters[0], cluster=cls.clusters[0],
local_context_data={'C': 3}, local_context_data={'C': 3},
start_on_boot=VirtualMachineStartOnBootChoices.STATUS_ON, start_on_boot=VirtualMachineStartOnBootChoices.STATUS_ON,
), ),
@@ -224,26 +340,106 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
cls.create_data = [ cls.create_data = [
{ {
'name': 'Virtual Machine 4', 'name': 'Virtual Machine 4',
'site': sites[1].pk, 'site': cls.sites[1].pk,
'cluster': clusters[1].pk, 'cluster': cls.clusters[1].pk,
'device': device2.pk, 'device': cls.devices[1].pk,
'virtual_machine_type': cls.vm_types[0].pk,
}, },
{ {
'name': 'Virtual Machine 5', 'name': 'Virtual Machine 5',
'site': sites[1].pk, 'site': cls.sites[1].pk,
'cluster': clusters[1].pk, 'cluster': cls.clusters[1].pk,
'virtual_machine_type': cls.vm_types[1].pk,
}, },
{ {
'name': 'Virtual Machine 6', 'name': 'Virtual Machine 6',
'site': sites[1].pk, 'site': cls.sites[1].pk,
}, },
{ {
'name': 'Virtual Machine 7', 'name': 'Virtual Machine 7',
'cluster': clusters[2].pk, 'cluster': cls.clusters[2].pk,
'virtual_machine_type': cls.vm_types[0].pk,
'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON, 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON,
}, },
] ]
def test_filter_by_virtual_machine_type(self):
self.add_permissions('virtualization.view_virtualmachine')
response = self.client.get(
f'{self._get_list_url()}?virtual_machine_type_id={self.vm_types[0].pk}',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 1)
response = self.client.get(
f'{self._get_list_url()}?virtual_machine_type={self.vm_types[0].slug}',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 1)
def test_virtual_machine_type_defaults_applied_on_create(self):
data = {
'name': 'Virtual Machine With Defaults',
'site': self.sites[1].pk,
'cluster': self.clusters[1].pk,
'virtual_machine_type': self.vm_types[0].pk,
'platform': None,
'vcpus': None,
'memory': None,
}
self.add_permissions('virtualization.add_virtualmachine')
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
vm = VirtualMachine.objects.get(pk=response.data['id'])
self.assertEqual(vm.virtual_machine_type, self.vm_types[0])
self.assertEqual(vm.platform, self.vm_types[0].default_platform)
self.assertEqual(vm.vcpus, self.vm_types[0].default_vcpus)
self.assertEqual(vm.memory, self.vm_types[0].default_memory)
def test_virtual_machine_type_defaults_do_not_override_explicit_values(self):
data = {
'name': 'Virtual Machine With Explicit Values',
'site': self.sites[1].pk,
'cluster': self.clusters[1].pk,
'virtual_machine_type': self.vm_types[0].pk,
'platform': self.platforms[2].pk,
'vcpus': 6,
'memory': 12288,
}
self.add_permissions('virtualization.add_virtualmachine')
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
vm = VirtualMachine.objects.get(pk=response.data['id'])
self.assertEqual(vm.virtual_machine_type, self.vm_types[0])
self.assertEqual(vm.platform, self.platforms[2])
self.assertEqual(vm.vcpus, 6)
self.assertEqual(vm.memory, 12288)
def test_setting_virtual_machine_type_on_existing_vm_does_not_backfill_defaults(self):
vm = VirtualMachine.objects.get(name='Virtual Machine 2')
self.add_permissions('virtualization.change_virtualmachine')
response = self.client.patch(
self._get_detail_url(vm),
{'virtual_machine_type': self.vm_types[1].pk},
format='json',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_200_OK)
vm.refresh_from_db()
self.assertEqual(vm.virtual_machine_type, self.vm_types[1])
self.assertIsNone(vm.platform)
self.assertIsNone(vm.vcpus)
self.assertIsNone(vm.memory)
def test_config_context_included_by_default_in_list_view(self): def test_config_context_included_by_default_in_list_view(self):
""" """
Check that config context data is included by default in the virtual machines list. Check that config context data is included by default in the virtual machines list.

View File

@@ -230,6 +230,116 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualMachineTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualMachineType.objects.all()
filterset = VirtualMachineTypeFilterSet
@classmethod
def setUpTestData(cls):
platforms = (
Platform(name='Platform 1', slug='platform-1'),
Platform(name='Platform 2', slug='platform-2'),
Platform(name='Platform 3', slug='platform-3'),
)
for platform in platforms:
platform.save()
cluster_type = ClusterType.objects.create(
name='Cluster Type 1',
slug='cluster-type-1',
)
site = Site.objects.create(
name='Site 1',
slug='site-1',
)
cluster = Cluster.objects.create(
name='Cluster 1',
type=cluster_type,
scope=site,
)
cls.vm_types = (
VirtualMachineType.objects.create(
name='Virtual Machine Type 1',
slug='virtual-machine-type-1',
default_platform=platforms[0],
default_vcpus=1,
default_memory=1024,
description='foobar1',
),
VirtualMachineType.objects.create(
name='Virtual Machine Type 2',
slug='virtual-machine-type-2',
default_platform=platforms[1],
default_vcpus=2,
default_memory=2048,
description='foobar2',
),
VirtualMachineType.objects.create(
name='Virtual Machine Type 3',
slug='virtual-machine-type-3',
default_platform=platforms[2],
default_vcpus=4,
default_memory=4096,
description='foobar3',
),
)
# Populate virtual_machine_count
VirtualMachine.objects.create(
name='vm-type-1a',
cluster=cluster,
virtual_machine_type=cls.vm_types[0],
)
VirtualMachine.objects.create(
name='vm-type-1b',
cluster=cluster,
virtual_machine_type=cls.vm_types[0],
tenant=Tenant.objects.create(name='Tenant 1', slug='tenant-1'),
)
VirtualMachine.objects.create(
name='vm-type-2a',
cluster=cluster,
virtual_machine_type=cls.vm_types[1],
)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Virtual Machine Type 1', 'Virtual Machine Type 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['virtual-machine-type-1', 'virtual-machine-type-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_default_platform(self):
platforms = Platform.objects.all()[:2]
params = {'default_platform_id': [platforms[0].pk, platforms[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'default_platform': [platforms[0].slug, platforms[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_default_vcpus(self):
params = {'default_vcpus': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_default_memory(self):
params = {'default_memory': [1024, 2048]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_machine_count(self):
params = {'virtual_machine_count': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualMachine.objects.all() queryset = VirtualMachine.objects.all()
filterset = VirtualMachineFilterSet filterset = VirtualMachineFilterSet
@@ -290,6 +400,30 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
for platform in platforms: for platform in platforms:
platform.save() platform.save()
cls.vm_types = (
VirtualMachineType.objects.create(
name='Virtual Machine Type 1',
slug='virtual-machine-type-1',
default_platform=platforms[0],
default_vcpus=1,
default_memory=1024,
),
VirtualMachineType.objects.create(
name='Virtual Machine Type 2',
slug='virtual-machine-type-2',
default_platform=platforms[1],
default_vcpus=2,
default_memory=2048,
),
VirtualMachineType.objects.create(
name='Virtual Machine Type 3',
slug='virtual-machine-type-3',
default_platform=platforms[2],
default_vcpus=4,
default_memory=4096,
),
)
roles = ( roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 2', slug='device-role-2'),
@@ -322,6 +456,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
vms = ( vms = (
VirtualMachine( VirtualMachine(
name='Virtual Machine 1', name='Virtual Machine 1',
virtual_machine_type=cls.vm_types[0],
site=sites[0], site=sites[0],
cluster=clusters[0], cluster=clusters[0],
device=devices[0], device=devices[0],
@@ -333,11 +468,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
memory=1, memory=1,
disk=1, disk=1,
description='foobar1', description='foobar1',
local_context_data={"foo": 123}, local_context_data={'foo': 123},
serial='111-aaa' serial='111-aaa',
), ),
VirtualMachine( VirtualMachine(
name='Virtual Machine 2', name='Virtual Machine 2',
virtual_machine_type=cls.vm_types[1],
site=sites[1], site=sites[1],
cluster=clusters[1], cluster=clusters[1],
device=devices[1], device=devices[1],
@@ -354,6 +490,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
), ),
VirtualMachine( VirtualMachine(
name='Virtual Machine 3', name='Virtual Machine 3',
virtual_machine_type=cls.vm_types[2],
site=sites[2], site=sites[2],
cluster=clusters[2], cluster=clusters[2],
device=devices[2], device=devices[2],
@@ -499,6 +636,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'platform': [platforms[0].slug, platforms[1].slug]} params = {'platform': [platforms[0].slug, platforms[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_machine_type(self):
vm_types = VirtualMachineType.objects.all()[:2]
params = {'virtual_machine_type_id': [vm_types[0].pk, vm_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'virtual_machine_type': [vm_types[0].slug, vm_types[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mac_address(self): def test_mac_address(self):
params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']} params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -1,12 +1,175 @@
from decimal import Decimal
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase from django.test import TestCase
from dcim.models import Site from dcim.models import Platform, Site
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.testing import create_test_device from utilities.testing import create_test_device
from virtualization.models import * from virtualization.models import *
class VirtualMachineTypeTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.platform = Platform.objects.create(
name='Type Test Ubuntu 24.04',
slug='type-test-ubuntu-24-04',
)
cls.virtual_machine_type = VirtualMachineType.objects.create(
name='Small Linux',
slug='small-linux',
default_platform=cls.platform,
default_vcpus=Decimal('2.00'),
default_memory=4096,
)
cls.cluster_type = ClusterType.objects.create(
name='VM Type Count Cluster Type',
slug='vm-type-count-cluster-type',
)
cls.site = Site.objects.create(
name='VM Type Count Site',
slug='vm-type-count-site',
)
cls.cluster = Cluster.objects.create(
name='VM Type Count Cluster',
type=cls.cluster_type,
scope=cls.site,
)
def test_virtual_machine_type_str_and_defaults(self):
"""
Verify that the string representation of a VirtualMachineType returns
its name, and that all default fields (platform, vcpus, memory) are
stored correctly after creation.
"""
self.assertEqual(str(self.virtual_machine_type), 'Small Linux')
self.assertEqual(self.virtual_machine_type.default_platform, self.platform)
self.assertEqual(self.virtual_machine_type.default_vcpus, Decimal('2.00'))
self.assertEqual(self.virtual_machine_type.default_memory, 4096)
def test_virtual_machine_type_duplicate_name_case_insensitive(self):
"""
Creating a VirtualMachineType whose name differs from an existing one
only by case should fail validation, enforced by the case-insensitive
unique constraint on Lower('name').
"""
virtual_machine_type = VirtualMachineType(
name='SMALL LINUX',
slug='small-linux-2',
)
with self.assertRaises(ValidationError):
virtual_machine_type.full_clean()
def test_virtual_machine_type_duplicate_slug(self):
"""
Creating a VirtualMachineType with a slug that already exists should
fail validation, enforced by the unique constraint on the slug field.
"""
virtual_machine_type = VirtualMachineType(
name='Medium Linux',
slug='small-linux',
)
with self.assertRaises(ValidationError):
virtual_machine_type.full_clean()
def test_virtual_machine_type_virtual_machine_count(self):
"""
The virtual_machine_count counter cache field should accurately track
the number of VirtualMachines referencing this type through creation,
additional insertions, reassignment, and deletion.
"""
# Starts at zero
self.assertEqual(self.virtual_machine_type.virtual_machine_count, 0)
# Create the first VM
vm1 = VirtualMachine.objects.create(
name='vm-count-test-1',
cluster=self.cluster,
virtual_machine_type=self.virtual_machine_type,
)
self.virtual_machine_type.refresh_from_db()
self.assertEqual(self.virtual_machine_type.virtual_machine_count, 1)
# Create the second VM
vm2 = VirtualMachine.objects.create(
name='vm-count-test-2',
cluster=self.cluster,
virtual_machine_type=self.virtual_machine_type,
)
self.virtual_machine_type.refresh_from_db()
self.assertEqual(self.virtual_machine_type.virtual_machine_count, 2)
# Delete one VM — count should decrement
vm1.delete()
self.virtual_machine_type.refresh_from_db()
self.assertEqual(self.virtual_machine_type.virtual_machine_count, 1)
# Reassign the remaining VM to no type — count should drop to zero
vm2.virtual_machine_type = None
vm2.save()
self.virtual_machine_type.refresh_from_db()
self.assertEqual(self.virtual_machine_type.virtual_machine_count, 0)
def test_virtual_machine_type_deletion_protected(self):
"""
Deleting a VirtualMachineType that is referenced by a VM should be prevented.
"""
VirtualMachine.objects.create(
name='vm-protect-test',
cluster=self.cluster,
virtual_machine_type=self.virtual_machine_type,
)
from django.db import models
with self.assertRaises(models.ProtectedError):
self.virtual_machine_type.delete()
def test_virtual_machine_type_deletion_without_vms(self):
"""
A VirtualMachineType with no associated VMs can be deleted.
"""
vmt = VirtualMachineType.objects.create(
name='Disposable Type',
slug='disposable-type',
)
pk = vmt.pk
vmt.delete()
self.assertFalse(VirtualMachineType.objects.filter(pk=pk).exists())
def test_virtual_machine_type_invalid_default_vcpus(self):
"""
default_vcpus below the minimum should fail validation.
"""
vmt = VirtualMachineType(
name='Zero vCPU Type',
slug='zero-vcpu-type',
default_vcpus=Decimal('0.00'),
)
with self.assertRaises(ValidationError):
vmt.full_clean()
def test_virtual_machine_type_minimal_fields(self):
"""
A VirtualMachineType with only name and slug should be valid.
"""
vmt = VirtualMachineType(
name='Bare Minimum',
slug='bare-minimum',
)
vmt.full_clean()
vmt.save()
self.assertIsNone(vmt.default_platform)
self.assertIsNone(vmt.default_vcpus)
self.assertIsNone(vmt.default_memory)
class VirtualMachineTestCase(TestCase): class VirtualMachineTestCase(TestCase):
@classmethod @classmethod
@@ -14,6 +177,12 @@ class VirtualMachineTestCase(TestCase):
# Create the cluster type # Create the cluster type
cls.cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cls.cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
# Create platforms
cls.platforms = (
Platform.objects.create(name='VM Default Ubuntu 24.04', slug='vm-default-ubuntu-24-04'),
Platform.objects.create(name='VM Default Debian 12', slug='vm-default-debian-12'),
)
# Create sites # Create sites
cls.sites = ( cls.sites = (
Site.objects.create(name='Site 1', slug='site-1'), Site.objects.create(name='Site 1', slug='site-1'),
@@ -59,6 +228,24 @@ class VirtualMachineTestCase(TestCase):
Tenant.objects.create(name='Tenant 2', slug='tenant-2'), Tenant.objects.create(name='Tenant 2', slug='tenant-2'),
) )
# Create virtual machine types
cls.vm_types = (
VirtualMachineType.objects.create(
name='General Purpose Small',
slug='general-purpose-small',
default_platform=cls.platforms[0],
default_vcpus=Decimal('2.00'),
default_memory=4096,
),
VirtualMachineType.objects.create(
name='General Purpose Large',
slug='general-purpose-large',
default_platform=cls.platforms[1],
default_vcpus=Decimal('8.00'),
default_memory=16384,
),
)
def test_vm_duplicate_name_per_cluster(self): def test_vm_duplicate_name_per_cluster(self):
""" """
Test that creating two Virtual Machines with the same name in Test that creating two Virtual Machines with the same name in
@@ -157,6 +344,181 @@ class VirtualMachineTestCase(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
vm.full_clean() vm.full_clean()
#
# Virtual Machine Type tests
#
def test_vm_type_defaults_applied_on_create(self):
"""
When a new VirtualMachine is created with a VirtualMachineType and no
explicit platform, vcpus, or memory, the type's defaults should be
automatically applied via apply_type_defaults().
"""
vm = VirtualMachine(
name='vm-type-defaults',
cluster=self.cluster_with_site,
virtual_machine_type=self.vm_types[0],
)
vm.full_clean()
vm.save()
vm.refresh_from_db()
self.assertEqual(vm.platform, self.platforms[0])
self.assertEqual(vm.vcpus, Decimal('2.00'))
self.assertEqual(vm.memory, 4096)
def test_vm_type_defaults_do_not_override_explicit_values(self):
"""
When a new VirtualMachine specifies explicit values for a platform,
vcpus, and memory, those values must be preserved even if the
assigned VirtualMachineType defines different defaults.
"""
vm = VirtualMachine(
name='vm-type-explicit',
cluster=self.cluster_with_site,
virtual_machine_type=self.vm_types[0],
platform=self.platforms[1],
vcpus=Decimal('4.00'),
memory=8192,
)
vm.full_clean()
vm.save()
vm.refresh_from_db()
self.assertEqual(vm.platform, self.platforms[1])
self.assertEqual(vm.vcpus, Decimal('4.00'))
self.assertEqual(vm.memory, 8192)
def test_vm_type_added_to_existing_vm_does_not_backfill_defaults(self):
"""
Assigning a VirtualMachineType to an already-saved VirtualMachine
(i.e. an update, not a creation) must not retroactively populate
the VM's fields with the type's defaults, since apply_type_defaults()
only runs on initial creation.
"""
vm = VirtualMachine(
name='vm-type-added-later',
cluster=self.cluster_with_site,
)
vm.full_clean()
vm.save()
vm.virtual_machine_type = self.vm_types[0]
vm.full_clean()
vm.save()
vm.refresh_from_db()
self.assertEqual(vm.virtual_machine_type, self.vm_types[0])
self.assertIsNone(vm.platform)
self.assertIsNone(vm.vcpus)
self.assertIsNone(vm.memory)
def test_vm_type_change_does_not_overwrite_existing_values(self):
"""
Changing the VirtualMachineType on an existing VirtualMachine must
not overwrite field values that were previously set — either
explicitly or via earlier type defaults.
"""
vm = VirtualMachine(
name='vm-type-change',
cluster=self.cluster_with_site,
virtual_machine_type=self.vm_types[0],
)
vm.full_clean()
vm.save()
vm.refresh_from_db()
self.assertEqual(vm.platform, self.platforms[0])
self.assertEqual(vm.vcpus, Decimal('2.00'))
self.assertEqual(vm.memory, 4096)
vm.platform = self.platforms[1]
vm.vcpus = Decimal('6.00')
vm.memory = 12288
vm.virtual_machine_type = self.vm_types[1]
vm.full_clean()
vm.save()
vm.refresh_from_db()
self.assertEqual(vm.platform, self.platforms[1])
self.assertEqual(vm.vcpus, Decimal('6.00'))
self.assertEqual(vm.memory, 12288)
self.assertEqual(vm.virtual_machine_type, self.vm_types[1])
def test_vm_type_partial_defaults(self):
"""
A VirtualMachineType with only some defaults set should only populate
those fields on a new VM, leaving the rest as None.
"""
partial_type = VirtualMachineType.objects.create(
name='Partial Defaults',
slug='partial-defaults',
default_vcpus=Decimal('4.00'),
# default_platform and default_memory intentionally left None
)
vm = VirtualMachine(
name='vm-partial-defaults',
cluster=self.cluster_with_site,
virtual_machine_type=partial_type,
)
vm.full_clean()
vm.save()
vm.refresh_from_db()
self.assertIsNone(vm.platform)
self.assertEqual(vm.vcpus, Decimal('4.00'))
self.assertIsNone(vm.memory)
def test_vm_type_no_defaults(self):
"""
A VirtualMachineType with all default fields as None should not
alter any VM fields on creation.
"""
empty_type = VirtualMachineType.objects.create(
name='Empty Type',
slug='empty-type',
)
vm = VirtualMachine(
name='vm-empty-type',
cluster=self.cluster_with_site,
virtual_machine_type=empty_type,
)
vm.full_clean()
vm.save()
vm.refresh_from_db()
self.assertEqual(vm.virtual_machine_type, empty_type)
self.assertIsNone(vm.platform)
self.assertIsNone(vm.vcpus)
self.assertIsNone(vm.memory)
def test_vm_created_without_type(self):
"""
A VM created without a VirtualMachineType should not raise any errors
in apply_type_defaults() and should leave all fields as None.
"""
vm = VirtualMachine(
name='vm-no-type',
cluster=self.cluster_with_site,
)
vm.full_clean()
vm.save()
vm.refresh_from_db()
self.assertIsNone(vm.virtual_machine_type)
self.assertIsNone(vm.platform)
self.assertIsNone(vm.vcpus)
self.assertIsNone(vm.memory)
def test_vm_type_is_included_in_clone_fields(self):
"""
Verify that virtual_machine_type is part of clone_fields so it
carries over when cloning a VM.
"""
self.assertIn('virtual_machine_type', VirtualMachine.clone_fields)
# #
# Device assignment tests # Device assignment tests
# #

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
@@ -202,6 +204,81 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
self.assertHttpStatus(self.client.get(url), 200) self.assertHttpStatus(self.client.get(url), 200)
class VirtualMachineTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualMachineType
@classmethod
def setUpTestData(cls):
cls.platforms = (
Platform(name='Platform 1', slug='platform-1'),
Platform(name='Platform 2', slug='platform-2'),
Platform(name='Platform 3', slug='platform-3'),
)
for platform in cls.platforms:
platform.save()
cls.virtual_machine_types = (
VirtualMachineType(
name='Virtual Machine Type 1',
slug='virtual-machine-type-1',
default_platform=cls.platforms[0],
default_vcpus=Decimal('1.00'),
default_memory=1024,
),
VirtualMachineType(
name='Virtual Machine Type 2',
slug='virtual-machine-type-2',
default_platform=cls.platforms[1],
default_vcpus=Decimal('2.00'),
default_memory=2048,
),
VirtualMachineType(
name='Virtual Machine Type 3',
slug='virtual-machine-type-3',
default_platform=cls.platforms[2],
default_vcpus=Decimal('4.00'),
default_memory=4096,
),
)
for virtual_machine_type in cls.virtual_machine_types:
virtual_machine_type.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Virtual Machine Type X',
'slug': 'virtual-machine-type-x',
'default_platform': cls.platforms[1].pk,
'default_vcpus': 8,
'default_memory': 8192,
'description': 'A new virtual machine type',
'comments': 'Some comments',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
'name,slug,default_platform,default_vcpus,default_memory,description',
'Virtual Machine Type 4,virtual-machine-type-4,Platform 1,1.00,1024,Fourth virtual machine type',
'Virtual Machine Type 5,virtual-machine-type-5,Platform 2,2.00,2048,Fifth virtual machine type',
'Virtual Machine Type 6,virtual-machine-type-6,Platform 3,4.00,4096,Sixth virtual machine type',
)
cls.csv_update_data = (
'id,name,description',
f'{cls.virtual_machine_types[0].pk},Virtual Machine Type 7,New description 7',
f'{cls.virtual_machine_types[1].pk},Virtual Machine Type 8,New description 8',
f'{cls.virtual_machine_types[2].pk},Virtual Machine Type 9,New description 9',
)
cls.bulk_edit_data = {
'default_platform': cls.platforms[2].pk,
'default_vcpus': 16,
'default_memory': 16384,
'description': 'New description',
}
class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualMachine model = VirtualMachine
@@ -215,57 +292,79 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
for role in roles: for role in roles:
role.save() role.save()
platforms = ( cls.platforms = (
Platform(name='Platform 1', slug='platform-1'), Platform(name='Platform 1', slug='platform-1'),
Platform(name='Platform 2', slug='platform-2'), Platform(name='Platform 2', slug='platform-2'),
) )
for platform in platforms: for platform in cls.platforms:
platform.save() platform.save()
sites = ( cls.sites = (
Site(name='Site 1', slug='site-1'), Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'), Site(name='Site 2', slug='site-2'),
) )
Site.objects.bulk_create(sites) Site.objects.bulk_create(cls.sites)
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clusters = ( cls.clusters = (
Cluster(name='Cluster 1', type=clustertype, scope=sites[0]), Cluster(name='Cluster 1', type=clustertype, scope=cls.sites[0]),
Cluster(name='Cluster 2', type=clustertype, scope=sites[1]), Cluster(name='Cluster 2', type=clustertype, scope=cls.sites[1]),
) )
for cluster in clusters: for cluster in cls.clusters:
cluster.save() cluster.save()
devices = ( cls.devices = (
create_test_device('device1', site=sites[0], cluster=clusters[0]), create_test_device('device1', site=cls.sites[0], cluster=cls.clusters[0]),
create_test_device('device2', site=sites[1], cluster=clusters[1]), create_test_device('device2', site=cls.sites[1], cluster=cls.clusters[1]),
) )
cls.vm_types = (
VirtualMachineType(
name='Virtual Machine Type 1',
slug='virtual-machine-type-1',
default_platform=cls.platforms[0],
default_vcpus=Decimal('2.00'),
default_memory=4096,
),
VirtualMachineType(
name='Virtual Machine Type 2',
slug='virtual-machine-type-2',
default_platform=cls.platforms[1],
default_vcpus=Decimal('4.00'),
default_memory=8192,
),
)
for vm_type in cls.vm_types:
vm_type.save()
virtual_machines = ( virtual_machines = (
VirtualMachine( VirtualMachine(
name='Virtual Machine 1', name='Virtual Machine 1',
site=sites[0], virtual_machine_type=cls.vm_types[0],
cluster=clusters[0], site=cls.sites[0],
device=devices[0], cluster=cls.clusters[0],
device=cls.devices[0],
role=roles[0], role=roles[0],
platform=platforms[0], platform=cls.platforms[0],
), ),
VirtualMachine( VirtualMachine(
name='Virtual Machine 2', name='Virtual Machine 2',
site=sites[0], virtual_machine_type=cls.vm_types[0],
cluster=clusters[0], site=cls.sites[0],
device=devices[0], cluster=cls.clusters[0],
device=cls.devices[0],
role=roles[0], role=roles[0],
platform=platforms[0], platform=cls.platforms[0],
), ),
VirtualMachine( VirtualMachine(
name='Virtual Machine 3', name='Virtual Machine 3',
site=sites[0], virtual_machine_type=cls.vm_types[1],
cluster=clusters[0], site=cls.sites[0],
device=devices[0], cluster=cls.clusters[0],
device=cls.devices[0],
role=roles[0], role=roles[0],
platform=platforms[0], platform=cls.platforms[0],
), ),
) )
VirtualMachine.objects.bulk_create(virtual_machines) VirtualMachine.objects.bulk_create(virtual_machines)
@@ -273,11 +372,12 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'cluster': clusters[1].pk, 'virtual_machine_type': cls.vm_types[1].pk,
'device': devices[1].pk, 'cluster': cls.clusters[1].pk,
'site': sites[1].pk, 'device': cls.devices[1].pk,
'site': cls.sites[1].pk,
'tenant': None, 'tenant': None,
'platform': platforms[1].pk, 'platform': cls.platforms[1].pk,
'name': 'Virtual Machine X', 'name': 'Virtual Machine X',
'status': VirtualMachineStatusChoices.STATUS_STAGED, 'status': VirtualMachineStatusChoices.STATUS_STAGED,
'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON, 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON,
@@ -294,34 +394,61 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
"name,status,site,cluster,device", 'name,status,site,cluster,device,virtual_machine_type',
"Virtual Machine 4,active,Site 1,Cluster 1,device1", 'Virtual Machine 4,active,Site 1,Cluster 1,device1,Virtual Machine Type 1',
"Virtual Machine 5,active,Site 1,Cluster 1,device1", 'Virtual Machine 5,active,Site 1,Cluster 1,device1,Virtual Machine Type 2',
"Virtual Machine 6,active,Site 1,Cluster 1,", 'Virtual Machine 6,active,Site 1,Cluster 1,,Virtual Machine Type 1',
) )
cls.csv_update_data = ( cls.csv_update_data = (
"id,name,comments", 'id,name,comments',
f"{virtual_machines[0].pk},Virtual Machine 7,New comments 7", f'{virtual_machines[0].pk},Virtual Machine 7,New comments 7',
f"{virtual_machines[1].pk},Virtual Machine 8,New comments 8", f'{virtual_machines[1].pk},Virtual Machine 8,New comments 8',
f"{virtual_machines[2].pk},Virtual Machine 9,New comments 9", f'{virtual_machines[2].pk},Virtual Machine 9,New comments 9',
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'site': sites[1].pk, 'virtual_machine_type': cls.vm_types[1].pk,
'cluster': clusters[1].pk, 'site': cls.sites[1].pk,
'device': devices[1].pk, 'cluster': cls.clusters[1].pk,
'device': cls.devices[1].pk,
'tenant': None, 'tenant': None,
'platform': platforms[1].pk, 'platform': cls.platforms[1].pk,
'status': VirtualMachineStatusChoices.STATUS_STAGED, 'status': VirtualMachineStatusChoices.STATUS_STAGED,
'role': roles[1].pk, 'role': roles[1].pk,
'vcpus': 8, 'vcpus': Decimal('8.00'),
'memory': 65535, 'memory': 65535,
'disk': 8000, 'disk': 8000,
'comments': 'New comments', 'comments': 'New comments',
'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_OFF, 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_OFF,
} }
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_create_virtualmachine_with_type_defaults(self):
self.add_permissions('virtualization.add_virtualmachine')
response = self.client.post(
self._get_url('add'),
data={
'name': 'Virtual Machine Defaults',
'virtual_machine_type': self.vm_types[0].pk,
'status': VirtualMachineStatusChoices.STATUS_ACTIVE,
'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_OFF,
'site': self.sites[0].pk,
'cluster': self.clusters[0].pk,
'platform': '',
'vcpus': '',
'memory': '',
},
)
self.assertHttpStatus(response, 302)
vm = VirtualMachine.objects.get(name='Virtual Machine Defaults')
self.assertEqual(vm.virtual_machine_type, self.vm_types[0])
self.assertEqual(vm.platform, self.platforms[0])
self.assertEqual(vm.vcpus, self.vm_types[0].default_vcpus)
self.assertEqual(vm.memory, self.vm_types[0].default_memory)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_virtualmachine_interfaces(self): def test_virtualmachine_interfaces(self):
virtualmachine = VirtualMachine.objects.first() virtualmachine = VirtualMachine.objects.first()

View File

@@ -2,6 +2,10 @@ from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels from netbox.ui import attrs, panels
#
# Cluster
#
class ClusterPanel(panels.ObjectAttributesPanel): class ClusterPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name') name = attrs.TextAttr('name')
@@ -13,8 +17,27 @@ class ClusterPanel(panels.ObjectAttributesPanel):
scope = attrs.GenericForeignKeyAttr('scope', linkify=True) scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
#
# Virtual machine types
#
class VirtualMachineTypePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
default_vcpus = attrs.TextAttr('default_vcpus', label=_('Default vCPUs'))
default_memory = attrs.TextAttr('default_memory', format_string=_('{} MB'), label=_('Default memory'))
description = attrs.TextAttr('description')
#
# Virtual machines
#
class VirtualMachinePanel(panels.ObjectAttributesPanel): class VirtualMachinePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name') name = attrs.TextAttr('name')
virtual_machine_type = attrs.RelatedObjectAttr('virtual_machine_type', linkify=True, label=_('Type'))
status = attrs.ChoiceAttr('status') status = attrs.ChoiceAttr('status')
start_on_boot = attrs.ChoiceAttr('start_on_boot') start_on_boot = attrs.ChoiceAttr('start_on_boot')
role = attrs.RelatedObjectAttr('role', linkify=True) role = attrs.RelatedObjectAttr('role', linkify=True)
@@ -44,6 +67,11 @@ class VirtualMachinePlacementPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True) device = attrs.RelatedObjectAttr('device', linkify=True)
#
# Virtual disks
#
class VirtualDiskPanel(panels.ObjectAttributesPanel): class VirtualDiskPanel(panels.ObjectAttributesPanel):
virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine')) virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine'))
name = attrs.TextAttr('name') name = attrs.TextAttr('name')
@@ -51,6 +79,11 @@ class VirtualDiskPanel(panels.ObjectAttributesPanel):
description = attrs.TextAttr('description') description = attrs.TextAttr('description')
#
# VM interfaces
#
class VMInterfacePanel(panels.ObjectAttributesPanel): class VMInterfacePanel(panels.ObjectAttributesPanel):
virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine')) virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine'))
name = attrs.TextAttr('name') name = attrs.TextAttr('name')

View File

@@ -16,6 +16,9 @@ urlpatterns = [
path('clusters/', include(get_model_urls('virtualization', 'cluster', detail=False))), path('clusters/', include(get_model_urls('virtualization', 'cluster', detail=False))),
path('clusters/<int:pk>/', include(get_model_urls('virtualization', 'cluster'))), path('clusters/<int:pk>/', include(get_model_urls('virtualization', 'cluster'))),
path('virtual-machine-types/', include(get_model_urls('virtualization', 'virtualmachinetype', detail=False))),
path('virtual-machine-types/<int:pk>/', include(get_model_urls('virtualization', 'virtualmachinetype'))),
path('virtual-machines/', include(get_model_urls('virtualization', 'virtualmachine', detail=False))), path('virtual-machines/', include(get_model_urls('virtualization', 'virtualmachine', detail=False))),
path('virtual-machines/<int:pk>/', include(get_model_urls('virtualization', 'virtualmachine'))), path('virtual-machines/<int:pk>/', include(get_model_urls('virtualization', 'virtualmachine'))),

View File

@@ -387,6 +387,80 @@ class ClusterAddDevicesView(generic.ObjectEditView):
}) })
#
# Virtual machine types
#
@register_model_view(VirtualMachineType, 'list', path='', detail=False)
class VirtualMachineTypeListView(generic.ObjectListView):
queryset = VirtualMachineType.objects.all()
filterset = filtersets.VirtualMachineTypeFilterSet
filterset_form = forms.VirtualMachineTypeFilterForm
table = tables.VirtualMachineTypeTable
@register_model_view(VirtualMachineType)
class VirtualMachineTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualMachineType.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualMachineTypePanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
ImageAttachmentsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(VirtualMachineType, 'add', detail=False)
@register_model_view(VirtualMachineType, 'edit')
class VirtualMachineTypeEditView(generic.ObjectEditView):
queryset = VirtualMachineType.objects.all()
form = forms.VirtualMachineTypeForm
@register_model_view(VirtualMachineType, 'delete')
class VirtualMachineTypeDeleteView(generic.ObjectDeleteView):
queryset = VirtualMachineType.objects.all()
@register_model_view(VirtualMachineType, 'bulk_import', path='import', detail=False)
class VirtualMachineTypeBulkImportView(generic.BulkImportView):
queryset = VirtualMachineType.objects.all()
model_form = forms.VirtualMachineTypeImportForm
@register_model_view(VirtualMachineType, 'bulk_edit', path='edit', detail=False)
class VirtualMachineTypeBulkEditView(generic.BulkEditView):
queryset = VirtualMachineType.objects.all()
filterset = filtersets.VirtualMachineTypeFilterSet
table = tables.VirtualMachineTypeTable
form = forms.VirtualMachineTypeBulkEditForm
@register_model_view(VirtualMachineType, 'bulk_rename', path='rename', detail=False)
class VirtualMachineTypeBulkRenameView(generic.BulkRenameView):
queryset = VirtualMachineType.objects.all()
filterset = filtersets.VirtualMachineTypeFilterSet
@register_model_view(VirtualMachineType, 'bulk_delete', path='delete', detail=False)
class VirtualMachineTypeBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualMachineType.objects.all()
filterset = filtersets.VirtualMachineTypeFilterSet
table = tables.VirtualMachineTypeTable
# #
# Virtual machines # Virtual machines
# #