mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-11 03:37:06 +02:00
feat(virtualization): Allow VMs to be assigned directly to devices (#21731)
Enable VMs to be assigned to a standalone device without requiring a cluster. Add device-scoped uniqueness constraints, update validation logic, and enhance placement flexibility. Site is now auto-inherited from the cluster or device.
This commit is contained in:
@@ -109,7 +109,8 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'cluster_id': '$cluster'
|
||||
'cluster_id': '$cluster',
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
@@ -151,7 +152,8 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
|
||||
|
||||
model = VirtualMachine
|
||||
fieldsets = (
|
||||
FieldSet('site', 'cluster', 'device', 'status', 'start_on_boot', 'role', 'tenant', 'platform', 'description'),
|
||||
FieldSet('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')),
|
||||
)
|
||||
@@ -264,13 +266,21 @@ class VMInterfaceBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
|
||||
interfaces = VMInterface.objects.filter(
|
||||
pk__in=self.initial['pk']
|
||||
).prefetch_related(
|
||||
'virtual_machine__site'
|
||||
'virtual_machine__site',
|
||||
'virtual_machine__cluster',
|
||||
'virtual_machine__device',
|
||||
)
|
||||
|
||||
# Check interface sites. First interface should set site, further interfaces will either continue the
|
||||
# loop or reset back to no site and break the loop.
|
||||
# Determine the effective site for each interface's VM (from its site,
|
||||
# cluster, or device). If all selected interfaces share the same site,
|
||||
# use it to filter VLAN choices; otherwise leave unfiltered.
|
||||
for interface in interfaces:
|
||||
vm_site = interface.virtual_machine.site or interface.virtual_machine.cluster._site
|
||||
vm = interface.virtual_machine
|
||||
vm_site = (
|
||||
vm.site
|
||||
or (vm.cluster and vm.cluster._site)
|
||||
or (vm.device and vm.device.site)
|
||||
)
|
||||
if site is None:
|
||||
site = vm_site
|
||||
elif vm_site is not site:
|
||||
|
||||
@@ -99,21 +99,21 @@ class VirtualMachineImportForm(PrimaryModelImportForm):
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text=_('Assigned site')
|
||||
help_text=_('Assigned site (inferred from cluster or device if omitted)')
|
||||
)
|
||||
cluster = CSVModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
queryset=Cluster.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text=_('Assigned cluster')
|
||||
help_text=_('Assigned cluster (required when the device belongs to a cluster)')
|
||||
)
|
||||
device = CSVModelChoiceField(
|
||||
label=_('Device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text=_('Assigned device within cluster')
|
||||
help_text=_('Host device (standalone or within a cluster)')
|
||||
)
|
||||
role = CSVModelChoiceField(
|
||||
label=_('Role'),
|
||||
|
||||
@@ -171,7 +171,11 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
help_text=_(
|
||||
'The site where this VM resides. Will be inferred automatically from the '
|
||||
'assigned cluster or device if left blank.'
|
||||
),
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
@@ -181,16 +185,21 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
|
||||
query_params={
|
||||
'site_id': ['$site', 'null']
|
||||
},
|
||||
help_text=_('Assign this VM to a cluster. Required when selecting a device that belongs to a cluster.'),
|
||||
)
|
||||
device = DynamicModelChoiceField(
|
||||
label=_('Device'),
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
query_params={
|
||||
'cluster_id': '$cluster',
|
||||
'site_id': '$site',
|
||||
},
|
||||
help_text=_("Optionally pin this VM to a specific host device within the cluster")
|
||||
help_text=_(
|
||||
'Optionally pin this VM to a specific host device within a cluster, '
|
||||
'or assign it directly to a standalone device.'
|
||||
)
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
label=_('Role'),
|
||||
@@ -218,7 +227,7 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'role', 'status', 'start_on_boot', 'description', 'serial', 'tags', name=_('Virtual Machine')),
|
||||
FieldSet('site', 'cluster', 'device', name=_('Site/Cluster')),
|
||||
FieldSet('site', 'cluster', 'device', name=_('Placement')),
|
||||
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
|
||||
FieldSet('platform', 'primary_ip4', 'primary_ip6', 'config_template', name=_('Management')),
|
||||
FieldSet('vcpus', 'memory', 'disk', name=_('Resources')),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import django.db.models.functions.text
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dcim', '0228_cable_bundle'),
|
||||
('extras', '0135_configtemplate_debug'),
|
||||
('ipam', '0088_rename_vlangroup_total_vlan_ids'),
|
||||
('tenancy', '0023_add_mptt_tree_indexes'),
|
||||
('users', '0015_owner'),
|
||||
('virtualization', '0052_gfk_indexes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='virtualmachine',
|
||||
constraint=models.UniqueConstraint(
|
||||
django.db.models.functions.text.Lower('name'),
|
||||
models.F('device'),
|
||||
models.F('tenant'),
|
||||
condition=models.Q(('cluster__isnull', True), ('device__isnull', False)),
|
||||
name='virtualization_virtualmachine_unique_name_device_tenant',
|
||||
violation_error_message='Virtual machine name must be unique per device and tenant.',
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='virtualmachine',
|
||||
constraint=models.UniqueConstraint(
|
||||
django.db.models.functions.text.Lower('name'),
|
||||
models.F('device'),
|
||||
condition=models.Q(('cluster__isnull', True), ('device__isnull', False), ('tenant__isnull', True)),
|
||||
name='virtualization_virtualmachine_unique_name_device',
|
||||
violation_error_message='Virtual machine name must be unique per device.',
|
||||
),
|
||||
),
|
||||
migrations.AlterConstraint(
|
||||
model_name='virtualmachine',
|
||||
name='virtualization_virtualmachine_unique_name_cluster_tenant',
|
||||
constraint=models.UniqueConstraint(
|
||||
django.db.models.functions.text.Lower('name'),
|
||||
models.F('cluster'),
|
||||
models.F('tenant'),
|
||||
name='virtualization_virtualmachine_unique_name_cluster_tenant',
|
||||
violation_error_message='Virtual machine name must be unique per cluster and tenant.',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -31,7 +31,16 @@ __all__ = (
|
||||
|
||||
class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel):
|
||||
"""
|
||||
A virtual machine which runs inside a Cluster.
|
||||
A virtual machine which runs on a Cluster or a standalone Device.
|
||||
|
||||
Each VM must be placed in at least one of three ways:
|
||||
|
||||
1. Assigned to a Site alone (e.g. for logical grouping without a specific host).
|
||||
2. Assigned to a Cluster and optionally pinned to a host Device within that cluster.
|
||||
3. Assigned directly to a standalone Device (one that does not belong to any cluster).
|
||||
|
||||
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.
|
||||
"""
|
||||
site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
@@ -155,22 +164,32 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
|
||||
clone_fields = (
|
||||
'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
|
||||
)
|
||||
prerequisite_models = (
|
||||
'virtualization.Cluster',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name', 'pk') # Name may be non-unique
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
Lower('name'), 'cluster', 'tenant',
|
||||
name='%(app_label)s_%(class)s_unique_name_cluster_tenant'
|
||||
name='%(app_label)s_%(class)s_unique_name_cluster_tenant',
|
||||
violation_error_message=_('Virtual machine name must be unique per cluster and tenant.')
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
Lower('name'), 'cluster',
|
||||
name='%(app_label)s_%(class)s_unique_name_cluster',
|
||||
condition=Q(tenant__isnull=True),
|
||||
violation_error_message=_("Virtual machine name must be unique per cluster.")
|
||||
violation_error_message=_('Virtual machine name must be unique per cluster.')
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
Lower('name'), 'device', 'tenant',
|
||||
name='%(app_label)s_%(class)s_unique_name_device_tenant',
|
||||
condition=Q(cluster__isnull=True, device__isnull=False),
|
||||
violation_error_message=_('Virtual machine name must be unique per device and tenant.')
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
Lower('name'), 'device',
|
||||
name='%(app_label)s_%(class)s_unique_name_device',
|
||||
condition=Q(cluster__isnull=True, device__isnull=False, tenant__isnull=True),
|
||||
violation_error_message=_('Virtual machine name must be unique per device.')
|
||||
),
|
||||
)
|
||||
verbose_name = _('virtual machine')
|
||||
@@ -182,11 +201,11 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Must be assigned to a site and/or cluster
|
||||
if not self.site and not self.cluster:
|
||||
raise ValidationError({
|
||||
'cluster': _('A virtual machine must be assigned to a site and/or cluster.')
|
||||
})
|
||||
# Must be assigned to a site, cluster, and/or device
|
||||
if not self.site and not self.cluster and not self.device:
|
||||
raise ValidationError(
|
||||
_('A virtual machine must be assigned to a site, cluster, or device.')
|
||||
)
|
||||
|
||||
# Validate site for cluster & VM
|
||||
if self.cluster and self.site and self.cluster._site and self.cluster._site != self.site:
|
||||
@@ -196,12 +215,25 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
|
||||
).format(cluster=self.cluster, site=self.site)
|
||||
})
|
||||
|
||||
# Validate assigned cluster device
|
||||
if self.device and not self.cluster:
|
||||
# Validate site for the device & VM (when the device is standalone)
|
||||
if self.device and self.site and self.device.site and self.device.site != self.site:
|
||||
raise ValidationError({
|
||||
'device': _('Must specify a cluster when assigning a host device.')
|
||||
'site': _(
|
||||
'The selected device ({device}) is not assigned to this site ({site}).'
|
||||
).format(device=self.device, site=self.site)
|
||||
})
|
||||
if self.device and self.device not in self.cluster.devices.all():
|
||||
|
||||
# Direct device assignment is only for standalone hosts. If the selected
|
||||
# device already belongs to a cluster, require that cluster explicitly.
|
||||
if self.device and not self.cluster and self.device.cluster:
|
||||
raise ValidationError({
|
||||
'cluster': _(
|
||||
"Must specify the assigned device's cluster ({cluster}) when assigning host device {device}."
|
||||
).format(cluster=self.device.cluster, device=self.device)
|
||||
})
|
||||
|
||||
# Validate assigned cluster device
|
||||
if self.device and self.cluster and self.device.cluster_id != self.cluster_id:
|
||||
raise ValidationError({
|
||||
'device': _(
|
||||
"The selected device ({device}) is not assigned to this cluster ({cluster})."
|
||||
@@ -243,10 +275,12 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Assign site from cluster if not set
|
||||
if self.cluster and not self.site:
|
||||
self.site = self.cluster._site
|
||||
# Assign a site from a cluster or device if not set
|
||||
if not self.site:
|
||||
if self.cluster and self.cluster._site:
|
||||
self.site = self.cluster._site
|
||||
elif self.device and self.device.site:
|
||||
self.site = self.device.site
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.test import TestCase
|
||||
|
||||
from dcim.models import Site
|
||||
from tenancy.models import Tenant
|
||||
from utilities.testing import create_test_device
|
||||
from virtualization.models import *
|
||||
|
||||
|
||||
@@ -10,29 +11,77 @@ class VirtualMachineTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
Cluster.objects.create(name='Cluster 1', type=cluster_type)
|
||||
# Create the cluster type
|
||||
cls.cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
|
||||
# Create sites
|
||||
cls.sites = (
|
||||
Site.objects.create(name='Site 1', slug='site-1'),
|
||||
Site.objects.create(name='Site 2', slug='site-2'),
|
||||
)
|
||||
|
||||
# Create clusters with various site scopes
|
||||
cls.cluster_with_site = Cluster.objects.create(
|
||||
name='Cluster with Site',
|
||||
type=cls.cluster_type,
|
||||
scope=cls.sites[0],
|
||||
)
|
||||
cls.cluster_with_site2 = Cluster.objects.create(
|
||||
name='Cluster with Site 2',
|
||||
type=cls.cluster_type,
|
||||
scope=cls.sites[1],
|
||||
)
|
||||
cls.cluster_no_site = Cluster.objects.create(
|
||||
name='Cluster No Site',
|
||||
type=cls.cluster_type,
|
||||
scope=None,
|
||||
)
|
||||
|
||||
# Create devices
|
||||
cls.device_in_cluster = create_test_device(
|
||||
'Device in Cluster',
|
||||
site=cls.sites[0],
|
||||
cluster=cls.cluster_with_site,
|
||||
)
|
||||
cls.device_in_cluster2 = create_test_device(
|
||||
'Device in Cluster 2',
|
||||
site=cls.sites[0],
|
||||
cluster=cls.cluster_with_site,
|
||||
)
|
||||
cls.standalone_device = create_test_device(
|
||||
'Standalone Device',
|
||||
site=cls.sites[1],
|
||||
)
|
||||
|
||||
# Create tenants
|
||||
cls.tenants = (
|
||||
Tenant.objects.create(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant.objects.create(name='Tenant 2', slug='tenant-2'),
|
||||
)
|
||||
|
||||
def test_vm_duplicate_name_per_cluster(self):
|
||||
"""
|
||||
Test that creating two Virtual Machines with the same name in
|
||||
the same cluster fails validation.
|
||||
"""
|
||||
vm1 = VirtualMachine(
|
||||
cluster=Cluster.objects.first(),
|
||||
name='Test VM 1'
|
||||
cluster=self.cluster_with_site,
|
||||
name='Test VM 1',
|
||||
)
|
||||
vm1.save()
|
||||
|
||||
vm2 = VirtualMachine(
|
||||
cluster=vm1.cluster,
|
||||
name=vm1.name
|
||||
name=vm1.name,
|
||||
)
|
||||
|
||||
# Two VMs assigned to the same Cluster and no Tenant should fail validation
|
||||
with self.assertRaises(ValidationError):
|
||||
vm2.full_clean()
|
||||
|
||||
tenant = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1')
|
||||
vm1.tenant = tenant
|
||||
vm1.tenant = self.tenants[0]
|
||||
vm1.save()
|
||||
vm2.tenant = tenant
|
||||
vm2.tenant = self.tenants[0]
|
||||
|
||||
# Two VMs assigned to the same Cluster and the same Tenant should fail validation
|
||||
with self.assertRaises(ValidationError):
|
||||
@@ -45,50 +94,38 @@ class VirtualMachineTestCase(TestCase):
|
||||
vm2.save()
|
||||
|
||||
def test_vm_mismatched_site_cluster(self):
|
||||
cluster_type = ClusterType.objects.first()
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=cluster_type, scope=sites[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]),
|
||||
Cluster(name='Cluster 3', type=cluster_type, scope=None),
|
||||
)
|
||||
for cluster in clusters:
|
||||
cluster.save()
|
||||
|
||||
"""
|
||||
Test that creating a Virtual Machine with a mismatched site and
|
||||
cluster fails validation.
|
||||
"""
|
||||
# VM with site only should pass
|
||||
VirtualMachine(name='vm1', site=sites[0]).full_clean()
|
||||
VirtualMachine(name='vm1', site=self.sites[0]).full_clean()
|
||||
|
||||
# VM with site, cluster non-site should pass
|
||||
VirtualMachine(name='vm1', site=sites[0], cluster=clusters[2]).full_clean()
|
||||
VirtualMachine(name='vm2', site=self.sites[0], cluster=self.cluster_no_site).full_clean()
|
||||
|
||||
# VM with non-site cluster only should pass
|
||||
VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()
|
||||
VirtualMachine(name='vm3', cluster=self.cluster_no_site).full_clean()
|
||||
|
||||
# VM with mismatched site & cluster should fail
|
||||
with self.assertRaises(ValidationError):
|
||||
VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean()
|
||||
VirtualMachine(name='vm4', site=self.sites[0], cluster=self.cluster_with_site2).full_clean()
|
||||
|
||||
# VM with cluster site but no direct site should have its site set automatically
|
||||
vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0])
|
||||
# VM with a cluster site but no direct site should have its site set automatically
|
||||
vm = VirtualMachine(name='vm5', site=None, cluster=self.cluster_with_site)
|
||||
vm.save()
|
||||
self.assertEqual(vm.site, sites[0])
|
||||
self.assertEqual(vm.site, self.sites[0])
|
||||
|
||||
def test_vm_name_case_sensitivity(self):
|
||||
vm1 = VirtualMachine(
|
||||
cluster=Cluster.objects.first(),
|
||||
name='virtual machine 1'
|
||||
cluster=self.cluster_with_site,
|
||||
name='virtual machine 1',
|
||||
)
|
||||
vm1.save()
|
||||
|
||||
vm2 = VirtualMachine(
|
||||
cluster=vm1.cluster,
|
||||
name='VIRTUAL MACHINE 1'
|
||||
name='VIRTUAL MACHINE 1',
|
||||
)
|
||||
|
||||
# Uniqueness validation for name should ignore case
|
||||
@@ -97,8 +134,8 @@ class VirtualMachineTestCase(TestCase):
|
||||
|
||||
def test_disk_size(self):
|
||||
vm = VirtualMachine(
|
||||
cluster=Cluster.objects.first(),
|
||||
name='Virtual Machine 1'
|
||||
cluster=self.cluster_with_site,
|
||||
name='VM Disk Test',
|
||||
)
|
||||
vm.save()
|
||||
vm.refresh_from_db()
|
||||
@@ -111,7 +148,7 @@ class VirtualMachineTestCase(TestCase):
|
||||
self.assertEqual(vm.disk, 20)
|
||||
|
||||
# Delete one VirtualDisk
|
||||
VirtualDisk.objects.first().delete()
|
||||
VirtualDisk.objects.filter(virtual_machine=vm).first().delete()
|
||||
vm.refresh_from_db()
|
||||
self.assertEqual(vm.disk, 10)
|
||||
|
||||
@@ -119,3 +156,228 @@ class VirtualMachineTestCase(TestCase):
|
||||
vm.disk = 30
|
||||
with self.assertRaises(ValidationError):
|
||||
vm.full_clean()
|
||||
|
||||
#
|
||||
# Device assignment tests
|
||||
#
|
||||
|
||||
def test_vm_assignment_valid_combinations(self):
|
||||
"""
|
||||
Test valid assignment combinations for VirtualMachine.
|
||||
"""
|
||||
# Valid: Site only
|
||||
VirtualMachine(name='vm-site-only', site=self.sites[0]).full_clean()
|
||||
|
||||
# Valid: Cluster only (cluster has a site scope)
|
||||
VirtualMachine(name='vm-cluster-only', cluster=self.cluster_with_site).full_clean()
|
||||
|
||||
# Valid: Cluster only (cluster has no site scope)
|
||||
VirtualMachine(name='vm-cluster-no-site', cluster=self.cluster_no_site).full_clean()
|
||||
|
||||
# Valid: Device only (standalone device, no cluster)
|
||||
VirtualMachine(name='vm-device-standalone', device=self.standalone_device).full_clean()
|
||||
|
||||
# Valid: Site + Cluster (matching)
|
||||
VirtualMachine(name='vm-site-cluster', site=self.sites[0], cluster=self.cluster_with_site).full_clean()
|
||||
|
||||
# Valid: Site + Cluster (cluster has no site scope)
|
||||
VirtualMachine(name='vm-site-cluster-no-scope', site=self.sites[0], cluster=self.cluster_no_site).full_clean()
|
||||
|
||||
# Valid: Cluster + Device (device belongs to the cluster)
|
||||
VirtualMachine(
|
||||
name='vm-cluster-device', cluster=self.cluster_with_site, device=self.device_in_cluster
|
||||
).full_clean()
|
||||
|
||||
# Valid: Site + Cluster + Device (all matching)
|
||||
VirtualMachine(
|
||||
name='vm-all-three',
|
||||
site=self.sites[0],
|
||||
cluster=self.cluster_with_site,
|
||||
device=self.device_in_cluster,
|
||||
).full_clean()
|
||||
|
||||
def test_vm_assignment_invalid_no_assignment(self):
|
||||
"""
|
||||
Test that a VirtualMachine without any assignment fails validation.
|
||||
"""
|
||||
vm = VirtualMachine(name='vm-no-assignment')
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
vm.full_clean()
|
||||
self.assertIn('__all__', context.exception.message_dict)
|
||||
|
||||
def test_vm_assignment_invalid_site_cluster_mismatch(self):
|
||||
"""
|
||||
Test that a VirtualMachine with a mismatched site and cluster fails validation.
|
||||
"""
|
||||
# VM with Site 2 but Cluster scoped to Site 1 should fail
|
||||
vm = VirtualMachine(name='vm-mismatch', site=self.sites[1], cluster=self.cluster_with_site)
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
vm.full_clean()
|
||||
self.assertIn('cluster', context.exception.message_dict)
|
||||
|
||||
def test_vm_assignment_invalid_device_in_cluster_without_cluster(self):
|
||||
"""
|
||||
Test that assigning a VM to a device that belongs to a cluster
|
||||
without specifying the cluster fails validation.
|
||||
"""
|
||||
# VM assigned to a device without specifying the cluster should fail
|
||||
vm = VirtualMachine(name='vm-device-no-cluster', device=self.device_in_cluster)
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
vm.full_clean()
|
||||
self.assertIn('cluster', context.exception.message_dict)
|
||||
|
||||
def test_vm_assignment_invalid_device_cluster_mismatch(self):
|
||||
"""
|
||||
Test that a VirtualMachine with a device and cluster that don't match fails validation.
|
||||
"""
|
||||
# VM with a device in cluster_with_site but assigned to cluster_with_site2 should fail
|
||||
vm = VirtualMachine(
|
||||
name='vm-device-wrong-cluster',
|
||||
device=self.device_in_cluster,
|
||||
cluster=self.cluster_with_site2,
|
||||
)
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
vm.full_clean()
|
||||
self.assertIn('device', context.exception.message_dict)
|
||||
|
||||
def test_vm_standalone_device_assignment(self):
|
||||
"""
|
||||
Test that a VirtualMachine can be assigned directly to a standalone device
|
||||
(device not in any cluster).
|
||||
"""
|
||||
# VM assigned to a standalone device only should pass
|
||||
vm = VirtualMachine(name='vm-standalone', device=self.standalone_device)
|
||||
vm.full_clean()
|
||||
vm.save()
|
||||
|
||||
# Verify the site was automatically set from the device
|
||||
self.assertEqual(vm.site, self.sites[1])
|
||||
self.assertIsNone(vm.cluster)
|
||||
|
||||
def test_vm_standalone_device_with_site(self):
|
||||
"""
|
||||
Test that a VirtualMachine can be assigned to a standalone device
|
||||
with an explicit matching site.
|
||||
"""
|
||||
# VM assigned to a standalone device with an explicit site should pass
|
||||
vm = VirtualMachine(name='vm-standalone-site', site=self.sites[1], device=self.standalone_device)
|
||||
vm.full_clean()
|
||||
vm.save()
|
||||
|
||||
self.assertEqual(vm.site, self.sites[1])
|
||||
self.assertEqual(vm.device, self.standalone_device)
|
||||
self.assertIsNone(vm.cluster)
|
||||
|
||||
def test_vm_duplicate_name_per_device(self):
|
||||
"""
|
||||
Test that VirtualMachine names must be unique per standalone device (when no cluster).
|
||||
"""
|
||||
vm1 = VirtualMachine(name='vm-dup', device=self.standalone_device)
|
||||
vm1.full_clean()
|
||||
vm1.save()
|
||||
|
||||
vm2 = VirtualMachine(name='vm-dup', device=self.standalone_device)
|
||||
|
||||
# Duplicate name on the same standalone device should fail
|
||||
with self.assertRaises(ValidationError):
|
||||
vm2.full_clean()
|
||||
|
||||
def test_vm_site_auto_assignment_from_device(self):
|
||||
"""
|
||||
Test that a VirtualMachine's site is automatically set from its assigned
|
||||
standalone device when no site is explicitly provided.
|
||||
"""
|
||||
# VM with a device only (no explicit site)
|
||||
vm = VirtualMachine(name='vm-auto-site', device=self.standalone_device)
|
||||
vm.full_clean()
|
||||
vm.save()
|
||||
|
||||
# Site should be automatically inherited from the device
|
||||
self.assertEqual(vm.site, self.sites[1])
|
||||
|
||||
def test_vm_duplicate_name_per_device_with_tenant(self):
|
||||
"""
|
||||
Test that VirtualMachine names can be duplicated across different tenants
|
||||
on the same standalone device.
|
||||
"""
|
||||
# Create VM with tenant1
|
||||
vm1 = VirtualMachine(name='vm-tenant-test', device=self.standalone_device, tenant=self.tenants[0])
|
||||
vm1.full_clean()
|
||||
vm1.save()
|
||||
|
||||
# The same name with tenant2 on the same device should pass
|
||||
vm2 = VirtualMachine(name='vm-tenant-test', device=self.standalone_device, tenant=self.tenants[1])
|
||||
vm2.full_clean()
|
||||
vm2.save()
|
||||
|
||||
# The same name with the same tenant should fail
|
||||
vm3 = VirtualMachine(name='vm-tenant-test', device=self.standalone_device, tenant=self.tenants[0])
|
||||
with self.assertRaises(ValidationError):
|
||||
vm3.full_clean()
|
||||
|
||||
def test_vm_device_name_case_sensitivity(self):
|
||||
"""
|
||||
Test that VirtualMachine name uniqueness per device is case-insensitive.
|
||||
"""
|
||||
vm1 = VirtualMachine(name='test vm', device=self.standalone_device)
|
||||
vm1.full_clean()
|
||||
vm1.save()
|
||||
|
||||
# The same name with a different case should fail
|
||||
vm2 = VirtualMachine(name='TEST VM', device=self.standalone_device)
|
||||
with self.assertRaises(ValidationError):
|
||||
vm2.full_clean()
|
||||
|
||||
def test_vm_cluster_device_with_site(self):
|
||||
"""
|
||||
Test that a VirtualMachine can be pinned to a device within a cluster
|
||||
with an explicit matching site.
|
||||
"""
|
||||
# VM with site + cluster + device (all matching)
|
||||
vm = VirtualMachine(
|
||||
name='vm-cluster-device-site',
|
||||
site=self.sites[0],
|
||||
cluster=self.cluster_with_site,
|
||||
device=self.device_in_cluster,
|
||||
)
|
||||
vm.full_clean()
|
||||
vm.save()
|
||||
|
||||
self.assertEqual(vm.site, self.sites[0])
|
||||
self.assertEqual(vm.cluster, self.cluster_with_site)
|
||||
self.assertEqual(vm.device, self.device_in_cluster)
|
||||
|
||||
def test_vm_move_between_devices_in_cluster(self):
|
||||
"""
|
||||
Test that a VirtualMachine can be moved between devices within the same cluster.
|
||||
"""
|
||||
# Create a VM pinned to device_in_cluster
|
||||
vm = VirtualMachine(name='vm-movable', cluster=self.cluster_with_site, device=self.device_in_cluster)
|
||||
vm.full_clean()
|
||||
vm.save()
|
||||
|
||||
# Move VM to device_in_cluster2
|
||||
vm.device = self.device_in_cluster2
|
||||
vm.full_clean()
|
||||
vm.save()
|
||||
|
||||
self.assertEqual(vm.device, self.device_in_cluster2)
|
||||
self.assertEqual(vm.cluster, self.cluster_with_site)
|
||||
|
||||
def test_vm_unpin_from_device(self):
|
||||
"""
|
||||
Test that a VirtualMachine can be unpinned from a device while remaining
|
||||
in the cluster.
|
||||
"""
|
||||
# Create a VM pinned to a device
|
||||
vm = VirtualMachine(name='vm-unpinnable', cluster=self.cluster_with_site, device=self.device_in_cluster)
|
||||
vm.full_clean()
|
||||
vm.save()
|
||||
|
||||
# Unpin VM from the device (keep in cluster)
|
||||
vm.device = None
|
||||
vm.full_clean()
|
||||
vm.save()
|
||||
|
||||
self.assertIsNone(vm.device)
|
||||
self.assertEqual(vm.cluster, self.cluster_with_site)
|
||||
|
||||
@@ -35,8 +35,8 @@ class VirtualMachinePanel(panels.ObjectAttributesPanel):
|
||||
)
|
||||
|
||||
|
||||
class VirtualMachineClusterPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Cluster')
|
||||
class VirtualMachinePlacementPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Placement')
|
||||
|
||||
site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
|
||||
cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
|
||||
|
||||
@@ -412,7 +412,7 @@ class VirtualMachineView(generic.ObjectView):
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.VirtualMachineClusterPanel(),
|
||||
panels.VirtualMachinePlacementPanel(),
|
||||
TemplatePanel('virtualization/panels/virtual_machine_resources.html'),
|
||||
ObjectsTablePanel(
|
||||
model='ipam.Service',
|
||||
|
||||
Reference in New Issue
Block a user