mirror of
https://github.com/netbox-community/netbox.git
synced 2026-03-29 05:42:09 +02:00
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:
@@ -74,6 +74,7 @@ These are considered the "core" application models which are used to model netwo
|
||||
* [tenancy.Tenant](../models/tenancy/tenant.md)
|
||||
* [virtualization.Cluster](../models/virtualization/cluster.md)
|
||||
* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
|
||||
* [virtualization.VirtualMachineType](../models/virtualization/virtualmachinetype.md)
|
||||
* [vpn.IKEPolicy](../models/vpn/ikepolicy.md)
|
||||
* [vpn.IKEProposal](../models/vpn/ikeproposal.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.Manufacturer](../models/dcim/manufacturer.md)
|
||||
* [dcim.Platform](../models/dcim/platform.md)
|
||||
* [dcim.RackGroup](../models/dcim/rackgroup.md)
|
||||
* [dcim.RackRole](../models/dcim/rackrole.md)
|
||||
* [ipam.ASNRange](../models/ipam/asnrange.md)
|
||||
* [ipam.RIR](../models/ipam/rir.md)
|
||||
|
||||
@@ -5,27 +5,37 @@ Virtual machines, clusters, and standalone hypervisors can be modeled in NetBox
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ClusterGroup & ClusterType --> Cluster
|
||||
Cluster --> VirtualMachine
|
||||
VirtualMachineType --> VirtualMachine
|
||||
Device --> VirtualMachine
|
||||
Cluster --> VirtualMachine
|
||||
Platform --> VirtualMachine
|
||||
VirtualMachine --> VMInterface
|
||||
|
||||
click Cluster "../../models/virtualization/cluster/"
|
||||
click ClusterGroup "../../models/virtualization/clustergroup/"
|
||||
click ClusterType "../../models/virtualization/clustertype/"
|
||||
click Device "../../models/dcim/device/"
|
||||
click Platform "../../models/dcim/platform/"
|
||||
click VirtualMachine "../../models/virtualization/virtualmachine/"
|
||||
click VMInterface "../../models/virtualization/vminterface/"
|
||||
click Cluster "../../models/virtualization/cluster/"
|
||||
click ClusterGroup "../../models/virtualization/clustergroup/"
|
||||
click ClusterType "../../models/virtualization/clustertype/"
|
||||
click VirtualMachineType "../../models/virtualization/virtualmachinetype/"
|
||||
click Device "../../models/dcim/device/"
|
||||
click Platform "../../models/dcim/platform/"
|
||||
click VirtualMachine "../../models/virtualization/virtualmachine/"
|
||||
click VMInterface "../../models/virtualization/vminterface/"
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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 **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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -56,11 +62,11 @@ Each VM may designate one primary IPv4 address and/or one primary IPv6 address f
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
27
docs/models/virtualization/virtualmachinetype.md
Normal file
27
docs/models/virtualization/virtualmachinetype.md
Normal 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.
|
||||
@@ -285,9 +285,10 @@ nav:
|
||||
- Cluster: 'models/virtualization/cluster.md'
|
||||
- ClusterGroup: 'models/virtualization/clustergroup.md'
|
||||
- ClusterType: 'models/virtualization/clustertype.md'
|
||||
- VMInterface: 'models/virtualization/vminterface.md'
|
||||
- VirtualDisk: 'models/virtualization/virtualdisk.md'
|
||||
- VirtualMachine: 'models/virtualization/virtualmachine.md'
|
||||
- VirtualMachineType: 'models/virtualization/virtualmachinetype.md'
|
||||
- VMInterface: 'models/virtualization/vminterface.md'
|
||||
- VPN:
|
||||
- IKEPolicy: 'models/vpn/ikepolicy.md'
|
||||
- IKEProposal: 'models/vpn/ikeproposal.md'
|
||||
|
||||
@@ -1305,6 +1305,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
'virtualdevicecontext',
|
||||
'virtualdisk',
|
||||
'virtualmachine',
|
||||
'virtualmachinetype',
|
||||
'vlan',
|
||||
'vlangroup',
|
||||
'vlantranslationpolicy',
|
||||
|
||||
@@ -270,6 +270,12 @@ VIRTUALIZATION_MENU = Menu(
|
||||
get_model_item('virtualization', 'virtualdisk', _('Virtual Disks')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
label=_('Virtual Machine Types'),
|
||||
items=(
|
||||
get_model_item('virtualization', 'virtualmachinetype', _('Virtual Machine Types')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
label=_('Clusters'),
|
||||
items=(
|
||||
|
||||
10
netbox/templates/virtualization/virtualmachinetype.html
Normal file
10
netbox/templates/virtualization/virtualmachinetype.html
Normal 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 %}
|
||||
@@ -16,10 +16,10 @@ from netbox.api.fields import ChoiceField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer
|
||||
from tenancy.api.serializers_.tenants import TenantSerializer
|
||||
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 ...choices import *
|
||||
from ...models import VirtualDisk, VirtualMachine, VirtualMachineType, VMInterface
|
||||
from .clusters import ClusterSerializer
|
||||
from .nested import NestedVMInterfaceSerializer
|
||||
|
||||
@@ -27,11 +27,29 @@ __all__ = (
|
||||
'VMInterfaceSerializer',
|
||||
'VirtualDiskSerializer',
|
||||
'VirtualMachineSerializer',
|
||||
'VirtualMachineTypeSerializer',
|
||||
'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):
|
||||
virtual_machine_type = VirtualMachineTypeSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
|
||||
start_on_boot = ChoiceField(choices=VirtualMachineStartOnBootChoices, required=False)
|
||||
site = SiteSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
@@ -52,10 +70,10 @@ class VirtualMachineSerializer(PrimaryModelSerializer):
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device',
|
||||
'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory',
|
||||
'disk', 'description', 'owner', 'comments', 'config_template', 'local_context_data', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'interface_count', 'virtual_disk_count',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot',
|
||||
'site', 'cluster', 'device', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory',
|
||||
'disk', 'description', 'serial', 'tenant', 'owner', 'comments', 'tags', 'local_context_data',
|
||||
'config_template', 'custom_fields', 'created', 'last_updated', 'interface_count', 'virtual_disk_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
@@ -65,10 +83,11 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
|
||||
|
||||
class Meta(VirtualMachineSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device',
|
||||
'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory',
|
||||
'disk', 'description', 'owner', 'comments', 'config_template', 'local_context_data', 'tags',
|
||||
'custom_fields', 'config_context', 'created', 'last_updated', 'interface_count', 'virtual_disk_count',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot',
|
||||
'site', 'cluster', 'device', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory',
|
||||
'disk', 'description', 'serial', 'tenant', 'owner', 'comments', 'tags', 'local_context_data',
|
||||
'config_template', 'custom_fields', 'created', 'last_updated', 'interface_count', 'virtual_disk_count',
|
||||
'config_context',
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
|
||||
@@ -10,6 +10,9 @@ router.register('cluster-types', views.ClusterTypeViewSet)
|
||||
router.register('cluster-groups', views.ClusterGroupViewSet)
|
||||
router.register('clusters', views.ClusterViewSet)
|
||||
|
||||
# Virtual machine types
|
||||
router.register('virtual-machine-types', views.VirtualMachineTypeViewSet)
|
||||
|
||||
# VirtualMachines
|
||||
router.register('virtual-machines', views.VirtualMachineViewSet)
|
||||
router.register('interfaces', views.VMInterfaceViewSet)
|
||||
|
||||
@@ -14,6 +14,7 @@ class VirtualizationRootView(APIRootView):
|
||||
"""
|
||||
Virtualization API root view
|
||||
"""
|
||||
|
||||
def get_view_name(self):
|
||||
return 'Virtualization'
|
||||
|
||||
@@ -22,6 +23,7 @@ class VirtualizationRootView(APIRootView):
|
||||
# Clusters
|
||||
#
|
||||
|
||||
|
||||
class ClusterTypeViewSet(NetBoxModelViewSet):
|
||||
queryset = ClusterType.objects.all()
|
||||
serializer_class = serializers.ClusterTypeSerializer
|
||||
@@ -44,10 +46,22 @@ class ClusterViewSet(NetBoxModelViewSet):
|
||||
filterset_class = filtersets.ClusterFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Virtual machine types
|
||||
#
|
||||
|
||||
|
||||
class VirtualMachineTypeViewSet(NetBoxModelViewSet):
|
||||
queryset = VirtualMachineType.objects.all()
|
||||
serializer_class = serializers.VirtualMachineTypeSerializer
|
||||
filterset_class = filtersets.VirtualMachineTypeFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
|
||||
class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
|
||||
queryset = VirtualMachine.objects.all()
|
||||
filterset_class = filtersets.VirtualMachineFilterSet
|
||||
|
||||
@@ -9,10 +9,10 @@ class VirtualizationConfig(AppConfig):
|
||||
from utilities.counters import connect_counters
|
||||
|
||||
from . import search, signals # noqa: F401
|
||||
from .models import VirtualMachine
|
||||
from .models import VirtualMachine, VirtualMachineType
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
||||
# Register counters
|
||||
connect_counters(VirtualMachine)
|
||||
connect_counters(VirtualMachine, VirtualMachineType)
|
||||
|
||||
@@ -26,6 +26,7 @@ __all__ = (
|
||||
'VMInterfaceFilterSet',
|
||||
'VirtualDiskFilterSet',
|
||||
'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
|
||||
class VirtualMachineFilterSet(
|
||||
PrimaryModelFilterSet,
|
||||
@@ -99,6 +139,18 @@ class VirtualMachineFilterSet(
|
||||
LocalConfigContextFilterSet,
|
||||
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(
|
||||
choices=VirtualMachineStatusChoices,
|
||||
distinct=False,
|
||||
|
||||
@@ -14,8 +14,9 @@ from utilities.forms import BulkRenameForm, add_blank_choice
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import *
|
||||
|
||||
from ..choices import *
|
||||
from ..models import *
|
||||
|
||||
__all__ = (
|
||||
'ClusterBulkEditForm',
|
||||
@@ -26,6 +27,7 @@ __all__ = (
|
||||
'VirtualDiskBulkEditForm',
|
||||
'VirtualDiskBulkRenameForm',
|
||||
'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):
|
||||
virtual_machine_type = DynamicModelChoiceField(
|
||||
label=_('Virtual machine type'),
|
||||
queryset=VirtualMachineType.objects.all(),
|
||||
required=False
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
choices=add_blank_choice(VirtualMachineStatusChoices),
|
||||
@@ -152,13 +184,14 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
|
||||
|
||||
model = VirtualMachine
|
||||
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('vcpus', 'memory', 'disk', name=_('Resources')),
|
||||
FieldSet('config_template', name=_('Configuration')),
|
||||
)
|
||||
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',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ from ipam.models import VLAN, VRF, VLANGroup
|
||||
from netbox.forms import NetBoxModelImportForm, OrganizationalModelImportForm, OwnerCSVMixin, PrimaryModelImportForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import *
|
||||
|
||||
from ..choices import *
|
||||
from ..models import *
|
||||
|
||||
__all__ = (
|
||||
'ClusterGroupImportForm',
|
||||
@@ -20,6 +21,7 @@ __all__ = (
|
||||
'VMInterfaceImportForm',
|
||||
'VirtualDiskImportForm',
|
||||
'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):
|
||||
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(
|
||||
label=_('Status'),
|
||||
choices=VirtualMachineStatusChoices,
|
||||
@@ -149,8 +175,9 @@ class VirtualMachineImportForm(PrimaryModelImportForm):
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = (
|
||||
'name', 'status', 'start_on_boot', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus',
|
||||
'memory', 'disk', 'description', 'serial', 'config_template', 'comments', 'owner', 'tags',
|
||||
'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device',
|
||||
'platform', 'vcpus', 'memory', 'disk', 'description', 'serial',
|
||||
'tenant', 'owner', 'comments', 'tags', 'config_template',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
|
||||
from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import *
|
||||
from vpn.models import L2VPN
|
||||
|
||||
from ..choices import *
|
||||
from ..models import *
|
||||
|
||||
__all__ = (
|
||||
'ClusterFilterForm',
|
||||
'ClusterGroupFilterForm',
|
||||
@@ -23,6 +24,7 @@ __all__ = (
|
||||
'VMInterfaceFilterForm',
|
||||
'VirtualDiskFilterForm',
|
||||
'VirtualMachineFilterForm',
|
||||
'VirtualMachineTypeFilterForm',
|
||||
)
|
||||
|
||||
|
||||
@@ -100,6 +102,43 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelF
|
||||
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(
|
||||
LocalConfigContextFilterForm,
|
||||
TenancyFilterForm,
|
||||
@@ -112,13 +151,20 @@ class VirtualMachineFilterForm(
|
||||
FieldSet('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id', name=_('Cluster')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'status', 'start_on_boot', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'config_template_id',
|
||||
'local_context_data', 'serial', name=_('Attributes')
|
||||
'virtual_machine_type_id', 'status', 'start_on_boot', 'role_id', 'platform_id', 'mac_address',
|
||||
'has_primary_ip', 'config_template_id', 'local_context_data', 'serial',
|
||||
name=_('Attributes')
|
||||
),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
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(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -14,10 +14,11 @@ from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelF
|
||||
from netbox.forms.mixins import OwnerMixin
|
||||
from tenancy.forms import TenancyForm
|
||||
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.widgets import HTMXSelect
|
||||
from virtualization.models import *
|
||||
|
||||
from ..models import *
|
||||
|
||||
__all__ = (
|
||||
'ClusterAddDevicesForm',
|
||||
@@ -28,6 +29,7 @@ __all__ = (
|
||||
'VMInterfaceForm',
|
||||
'VirtualDiskForm',
|
||||
'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):
|
||||
virtual_machine_type = DynamicModelChoiceField(
|
||||
label=_('Type'),
|
||||
queryset=VirtualMachineType.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
@@ -226,7 +256,10 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
|
||||
)
|
||||
|
||||
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('tenant_group', 'tenant', name=_('Tenancy')),
|
||||
FieldSet('platform', 'primary_ip4', 'primary_ip6', 'config_template', name=_('Management')),
|
||||
@@ -237,9 +270,9 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = [
|
||||
'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant',
|
||||
'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial', 'owner',
|
||||
'comments', 'tags', 'local_context_data', 'config_template',
|
||||
'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device',
|
||||
'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial',
|
||||
'tenant_group', 'tenant', 'owner', 'comments', 'tags', 'local_context_data', 'config_template',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
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 extras.graphql.filter_mixins import ConfigContextFilterMixin
|
||||
@@ -34,6 +34,7 @@ __all__ = (
|
||||
'VMInterfaceFilter',
|
||||
'VirtualDiskFilter',
|
||||
'VirtualMachineFilter',
|
||||
'VirtualMachineTypeFilter',
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +69,24 @@ class ClusterTypeFilter(OrganizationalModelFilter):
|
||||
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)
|
||||
class VirtualMachineFilter(
|
||||
ContactFilterMixin,
|
||||
@@ -78,6 +97,9 @@ class VirtualMachineFilter(
|
||||
PrimaryModelFilter,
|
||||
):
|
||||
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_id: ID | None = strawberry_django.filter_field()
|
||||
cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
|
||||
@@ -92,9 +114,7 @@ class VirtualMachineFilter(
|
||||
platform_id: ID | None = strawberry_django.filter_field()
|
||||
status: (
|
||||
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 = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -117,8 +137,6 @@ class VirtualMachineFilter(
|
||||
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 = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -129,10 +147,11 @@ class VirtualMachineFilter(
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
start_on_boot: (
|
||||
BaseFilterLookup[Annotated['VirtualMachineStartOnBootEnum', strawberry.lazy('virtualization.graphql.enums')]
|
||||
] | None) = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
BaseFilterLookup[Annotated['VirtualMachineStartOnBootEnum', strawberry.lazy('virtualization.graphql.enums')]]
|
||||
| None
|
||||
) = 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)
|
||||
|
||||
@@ -4,7 +4,7 @@ import strawberry_django
|
||||
from .types import *
|
||||
|
||||
|
||||
@strawberry.type(name="Query")
|
||||
@strawberry.type(name='Query')
|
||||
class VirtualizationQuery:
|
||||
cluster: 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_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_list: list[VirtualMachineType] = strawberry_django.field()
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ __all__ = (
|
||||
'VMInterfaceType',
|
||||
'VirtualDiskType',
|
||||
'VirtualMachineType',
|
||||
'VirtualMachineTypeType',
|
||||
)
|
||||
|
||||
|
||||
@@ -91,6 +92,19 @@ class ClusterTypeType(OrganizationalObjectType):
|
||||
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(
|
||||
models.VirtualMachine,
|
||||
fields='__all__',
|
||||
@@ -101,6 +115,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, PrimaryObjectType):
|
||||
interface_count: BigInt
|
||||
virtual_disk_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
|
||||
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
cluster: Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||
|
||||
106
netbox/virtualization/migrations/0054_virtualmachinetype.py
Normal file
106
netbox/virtualization/migrations/0054_virtualmachinetype.py
Normal 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.',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -20,16 +20,88 @@ from utilities.fields import CounterCacheField, NaturalOrderingField
|
||||
from utilities.ordering import naturalize_interface
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from utilities.tracking import TrackingModelMixin
|
||||
from virtualization.choices import *
|
||||
|
||||
from ..choices import *
|
||||
|
||||
__all__ = (
|
||||
'VMInterface',
|
||||
'VirtualDisk',
|
||||
'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.
|
||||
|
||||
@@ -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.
|
||||
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(
|
||||
to='dcim.Site',
|
||||
on_delete=models.PROTECT,
|
||||
@@ -162,7 +243,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
|
||||
objects = ConfigContextModelQuerySet.as_manager()
|
||||
|
||||
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:
|
||||
@@ -282,8 +364,29 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
|
||||
elif self.device and self.device.site:
|
||||
self.site = self.device.site
|
||||
|
||||
if self._state.adding:
|
||||
self.apply_type_defaults()
|
||||
|
||||
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):
|
||||
return VirtualMachineStatusChoices.colors.get(self.status)
|
||||
|
||||
|
||||
@@ -38,6 +38,18 @@ class ClusterTypeIndex(SearchIndex):
|
||||
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
|
||||
class VirtualMachineIndex(SearchIndex):
|
||||
model = models.VirtualMachine
|
||||
|
||||
@@ -5,28 +5,64 @@ from dcim.tables.devices import BaseInterfaceTable
|
||||
from netbox.tables import NetBoxTable, PrimaryModelTable, columns
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
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 *
|
||||
|
||||
__all__ = (
|
||||
'VMInterfaceTable',
|
||||
'VirtualDiskTable',
|
||||
'VirtualMachineTable',
|
||||
'VirtualMachineTypeTable',
|
||||
'VirtualMachineVMInterfaceTable',
|
||||
'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
|
||||
#
|
||||
|
||||
|
||||
class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
virtual_machine_type = tables.Column(
|
||||
verbose_name=_('Type'),
|
||||
linkify=True,
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
)
|
||||
@@ -85,12 +121,13 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModel
|
||||
class Meta(PrimaryModelTable.Meta):
|
||||
model = VirtualMachine
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', 'role', 'tenant',
|
||||
'tenant_group', 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description',
|
||||
'comments', 'config_template', 'serial', 'contacts', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device',
|
||||
'platform', 'primary_ip4', 'primary_ip6', 'primary_ip', 'vcpus', 'memory', 'disk', 'description', 'serial',
|
||||
'tenant_group', 'tenant', 'contacts', 'comments', 'tags', 'config_template', 'created', 'last_updated',
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -7,7 +7,7 @@ from rest_framework import status
|
||||
|
||||
from core.models import ObjectType
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import Site
|
||||
from dcim.models import Platform, Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import ConfigTemplate, CustomField
|
||||
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):
|
||||
model = VirtualMachine
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'staged',
|
||||
}
|
||||
user_permissions = ('dcim.view_platform', 'virtualization.view_virtualmachinetype')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-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 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
Site.objects.bulk_create(cls.sites)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=clustertype, scope=sites[0], group=clustergroup),
|
||||
Cluster(name='Cluster 2', type=clustertype, scope=sites[1], group=clustergroup),
|
||||
cls.clusters = (
|
||||
Cluster(name='Cluster 1', type=clustertype, scope=cls.sites[0], group=clustergroup),
|
||||
Cluster(name='Cluster 2', type=clustertype, scope=cls.sites[1], group=clustergroup),
|
||||
Cluster(name='Cluster 3', type=clustertype),
|
||||
)
|
||||
for cluster in clusters:
|
||||
for cluster in cls.clusters:
|
||||
cluster.save()
|
||||
|
||||
device1 = create_test_device('device1', site=sites[0], cluster=clusters[0])
|
||||
device2 = create_test_device('device2', site=sites[1], cluster=clusters[1])
|
||||
cls.devices = (
|
||||
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 = (
|
||||
VirtualMachine(
|
||||
name='Virtual Machine 1',
|
||||
site=sites[0],
|
||||
cluster=clusters[0],
|
||||
device=device1,
|
||||
virtual_machine_type=cls.vm_types[0],
|
||||
site=cls.sites[0],
|
||||
cluster=cls.clusters[0],
|
||||
device=cls.devices[0],
|
||||
platform=cls.platforms[0],
|
||||
vcpus=2,
|
||||
memory=4096,
|
||||
local_context_data={'A': 1},
|
||||
),
|
||||
VirtualMachine(
|
||||
name='Virtual Machine 2',
|
||||
site=sites[0],
|
||||
cluster=clusters[0],
|
||||
local_context_data={'B': 2
|
||||
}),
|
||||
site=cls.sites[0],
|
||||
cluster=cls.clusters[0],
|
||||
local_context_data={'B': 2},
|
||||
),
|
||||
VirtualMachine(
|
||||
name='Virtual Machine 3',
|
||||
site=sites[0],
|
||||
cluster=clusters[0],
|
||||
site=cls.sites[0],
|
||||
cluster=cls.clusters[0],
|
||||
local_context_data={'C': 3},
|
||||
start_on_boot=VirtualMachineStartOnBootChoices.STATUS_ON,
|
||||
),
|
||||
@@ -224,26 +340,106 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Virtual Machine 4',
|
||||
'site': sites[1].pk,
|
||||
'cluster': clusters[1].pk,
|
||||
'device': device2.pk,
|
||||
'site': cls.sites[1].pk,
|
||||
'cluster': cls.clusters[1].pk,
|
||||
'device': cls.devices[1].pk,
|
||||
'virtual_machine_type': cls.vm_types[0].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Virtual Machine 5',
|
||||
'site': sites[1].pk,
|
||||
'cluster': clusters[1].pk,
|
||||
'site': cls.sites[1].pk,
|
||||
'cluster': cls.clusters[1].pk,
|
||||
'virtual_machine_type': cls.vm_types[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Virtual Machine 6',
|
||||
'site': sites[1].pk,
|
||||
'site': cls.sites[1].pk,
|
||||
},
|
||||
{
|
||||
'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,
|
||||
},
|
||||
]
|
||||
|
||||
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):
|
||||
"""
|
||||
Check that config context data is included by default in the virtual machines list.
|
||||
|
||||
@@ -230,6 +230,116 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
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):
|
||||
queryset = VirtualMachine.objects.all()
|
||||
filterset = VirtualMachineFilterSet
|
||||
@@ -290,6 +400,30 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
for platform in platforms:
|
||||
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 = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
@@ -322,6 +456,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
vms = (
|
||||
VirtualMachine(
|
||||
name='Virtual Machine 1',
|
||||
virtual_machine_type=cls.vm_types[0],
|
||||
site=sites[0],
|
||||
cluster=clusters[0],
|
||||
device=devices[0],
|
||||
@@ -333,11 +468,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
memory=1,
|
||||
disk=1,
|
||||
description='foobar1',
|
||||
local_context_data={"foo": 123},
|
||||
serial='111-aaa'
|
||||
local_context_data={'foo': 123},
|
||||
serial='111-aaa',
|
||||
),
|
||||
VirtualMachine(
|
||||
name='Virtual Machine 2',
|
||||
virtual_machine_type=cls.vm_types[1],
|
||||
site=sites[1],
|
||||
cluster=clusters[1],
|
||||
device=devices[1],
|
||||
@@ -354,6 +490,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
),
|
||||
VirtualMachine(
|
||||
name='Virtual Machine 3',
|
||||
virtual_machine_type=cls.vm_types[2],
|
||||
site=sites[2],
|
||||
cluster=clusters[2],
|
||||
device=devices[2],
|
||||
@@ -499,6 +636,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'platform': [platforms[0].slug, platforms[1].slug]}
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -1,12 +1,175 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Site
|
||||
from dcim.models import Platform, Site
|
||||
from tenancy.models import Tenant
|
||||
from utilities.testing import create_test_device
|
||||
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):
|
||||
|
||||
@classmethod
|
||||
@@ -14,6 +177,12 @@ class VirtualMachineTestCase(TestCase):
|
||||
# Create the cluster type
|
||||
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
|
||||
cls.sites = (
|
||||
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'),
|
||||
)
|
||||
|
||||
# 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):
|
||||
"""
|
||||
Test that creating two Virtual Machines with the same name in
|
||||
@@ -157,6 +344,181 @@ class VirtualMachineTestCase(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
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
|
||||
#
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
@@ -202,6 +204,81 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
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):
|
||||
model = VirtualMachine
|
||||
|
||||
@@ -215,57 +292,79 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
for role in roles:
|
||||
role.save()
|
||||
|
||||
platforms = (
|
||||
cls.platforms = (
|
||||
Platform(name='Platform 1', slug='platform-1'),
|
||||
Platform(name='Platform 2', slug='platform-2'),
|
||||
)
|
||||
for platform in platforms:
|
||||
for platform in cls.platforms:
|
||||
platform.save()
|
||||
|
||||
sites = (
|
||||
cls.sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
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')
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=clustertype, scope=sites[0]),
|
||||
Cluster(name='Cluster 2', type=clustertype, scope=sites[1]),
|
||||
cls.clusters = (
|
||||
Cluster(name='Cluster 1', type=clustertype, scope=cls.sites[0]),
|
||||
Cluster(name='Cluster 2', type=clustertype, scope=cls.sites[1]),
|
||||
)
|
||||
for cluster in clusters:
|
||||
for cluster in cls.clusters:
|
||||
cluster.save()
|
||||
|
||||
devices = (
|
||||
create_test_device('device1', site=sites[0], cluster=clusters[0]),
|
||||
create_test_device('device2', site=sites[1], cluster=clusters[1]),
|
||||
cls.devices = (
|
||||
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.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 = (
|
||||
VirtualMachine(
|
||||
name='Virtual Machine 1',
|
||||
site=sites[0],
|
||||
cluster=clusters[0],
|
||||
device=devices[0],
|
||||
virtual_machine_type=cls.vm_types[0],
|
||||
site=cls.sites[0],
|
||||
cluster=cls.clusters[0],
|
||||
device=cls.devices[0],
|
||||
role=roles[0],
|
||||
platform=platforms[0],
|
||||
platform=cls.platforms[0],
|
||||
),
|
||||
VirtualMachine(
|
||||
name='Virtual Machine 2',
|
||||
site=sites[0],
|
||||
cluster=clusters[0],
|
||||
device=devices[0],
|
||||
virtual_machine_type=cls.vm_types[0],
|
||||
site=cls.sites[0],
|
||||
cluster=cls.clusters[0],
|
||||
device=cls.devices[0],
|
||||
role=roles[0],
|
||||
platform=platforms[0],
|
||||
platform=cls.platforms[0],
|
||||
),
|
||||
VirtualMachine(
|
||||
name='Virtual Machine 3',
|
||||
site=sites[0],
|
||||
cluster=clusters[0],
|
||||
device=devices[0],
|
||||
virtual_machine_type=cls.vm_types[1],
|
||||
site=cls.sites[0],
|
||||
cluster=cls.clusters[0],
|
||||
device=cls.devices[0],
|
||||
role=roles[0],
|
||||
platform=platforms[0],
|
||||
platform=cls.platforms[0],
|
||||
),
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(virtual_machines)
|
||||
@@ -273,11 +372,12 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'cluster': clusters[1].pk,
|
||||
'device': devices[1].pk,
|
||||
'site': sites[1].pk,
|
||||
'virtual_machine_type': cls.vm_types[1].pk,
|
||||
'cluster': cls.clusters[1].pk,
|
||||
'device': cls.devices[1].pk,
|
||||
'site': cls.sites[1].pk,
|
||||
'tenant': None,
|
||||
'platform': platforms[1].pk,
|
||||
'platform': cls.platforms[1].pk,
|
||||
'name': 'Virtual Machine X',
|
||||
'status': VirtualMachineStatusChoices.STATUS_STAGED,
|
||||
'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON,
|
||||
@@ -294,34 +394,61 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,status,site,cluster,device",
|
||||
"Virtual Machine 4,active,Site 1,Cluster 1,device1",
|
||||
"Virtual Machine 5,active,Site 1,Cluster 1,device1",
|
||||
"Virtual Machine 6,active,Site 1,Cluster 1,",
|
||||
'name,status,site,cluster,device,virtual_machine_type',
|
||||
'Virtual Machine 4,active,Site 1,Cluster 1,device1,Virtual Machine Type 1',
|
||||
'Virtual Machine 5,active,Site 1,Cluster 1,device1,Virtual Machine Type 2',
|
||||
'Virtual Machine 6,active,Site 1,Cluster 1,,Virtual Machine Type 1',
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name,comments",
|
||||
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[2].pk},Virtual Machine 9,New comments 9",
|
||||
'id,name,comments',
|
||||
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[2].pk},Virtual Machine 9,New comments 9',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'site': sites[1].pk,
|
||||
'cluster': clusters[1].pk,
|
||||
'device': devices[1].pk,
|
||||
'virtual_machine_type': cls.vm_types[1].pk,
|
||||
'site': cls.sites[1].pk,
|
||||
'cluster': cls.clusters[1].pk,
|
||||
'device': cls.devices[1].pk,
|
||||
'tenant': None,
|
||||
'platform': platforms[1].pk,
|
||||
'platform': cls.platforms[1].pk,
|
||||
'status': VirtualMachineStatusChoices.STATUS_STAGED,
|
||||
'role': roles[1].pk,
|
||||
'vcpus': 8,
|
||||
'vcpus': Decimal('8.00'),
|
||||
'memory': 65535,
|
||||
'disk': 8000,
|
||||
'comments': 'New comments',
|
||||
'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=['*'])
|
||||
def test_virtualmachine_interfaces(self):
|
||||
virtualmachine = VirtualMachine.objects.first()
|
||||
|
||||
@@ -2,6 +2,10 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.ui import attrs, panels
|
||||
|
||||
#
|
||||
# Cluster
|
||||
#
|
||||
|
||||
|
||||
class ClusterPanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
@@ -13,8 +17,27 @@ class ClusterPanel(panels.ObjectAttributesPanel):
|
||||
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):
|
||||
name = attrs.TextAttr('name')
|
||||
virtual_machine_type = attrs.RelatedObjectAttr('virtual_machine_type', linkify=True, label=_('Type'))
|
||||
status = attrs.ChoiceAttr('status')
|
||||
start_on_boot = attrs.ChoiceAttr('start_on_boot')
|
||||
role = attrs.RelatedObjectAttr('role', linkify=True)
|
||||
@@ -44,6 +67,11 @@ class VirtualMachinePlacementPanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
|
||||
|
||||
#
|
||||
# Virtual disks
|
||||
#
|
||||
|
||||
|
||||
class VirtualDiskPanel(panels.ObjectAttributesPanel):
|
||||
virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine'))
|
||||
name = attrs.TextAttr('name')
|
||||
@@ -51,6 +79,11 @@ class VirtualDiskPanel(panels.ObjectAttributesPanel):
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
#
|
||||
# VM interfaces
|
||||
#
|
||||
|
||||
|
||||
class VMInterfacePanel(panels.ObjectAttributesPanel):
|
||||
virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine'))
|
||||
name = attrs.TextAttr('name')
|
||||
|
||||
@@ -16,6 +16,9 @@ urlpatterns = [
|
||||
path('clusters/', include(get_model_urls('virtualization', 'cluster', detail=False))),
|
||||
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/<int:pk>/', include(get_model_urls('virtualization', 'virtualmachine'))),
|
||||
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user