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:
Martin Hauser
2026-03-25 18:20:00 +01:00
committed by GitHub
parent 29239ca58a
commit 2c0b6c4d55
10 changed files with 464 additions and 83 deletions

View File

@@ -1,17 +1,19 @@
# Virtualization
Virtual machines and clusters can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks.
Virtual machines, clusters, and standalone hypervisors can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks.
```mermaid
flowchart TD
ClusterGroup & ClusterType --> Cluster
Cluster --> VirtualMachine
Device --> 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/"
@@ -23,4 +25,10 @@ A cluster is one or more physical host devices on which virtual machines can run
## 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 also 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 be placed in one of three ways:
- Assigned to a site alone for logical grouping.
- Assigned to a cluster and optionally pinned to a specific host device within that cluster.
- Assigned directly to a standalone device that does not belong to any cluster.

View File

@@ -1,18 +1,21 @@
# Virtual Machines
A virtual machine (VM) represents a virtual compute instance hosted within a [cluster](./cluster.md). Each VM must be assigned to a [site](../dcim/site.md) and/or cluster, and may optionally be assigned to a particular host [device](../dcim/device.md) within a cluster.
A virtual machine (VM) represents a virtual compute instance hosted within a cluster or directly on a device. Each VM must be assigned to at least one of: a [site](../dcim/site.md), a [cluster](./cluster.md), or a [device](../dcim/device.md).
Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the device can be designated, for both IPv4 and IPv6.
Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the VM can be designated, for both IPv4 and IPv6.
## Fields
### Name
The virtual machine's configured name. Must be unique to the assigned cluster and tenant.
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.
### Role
The functional [role](../dcim/devicerole.md) assigned to the VM.
The functional role assigned to the VM.
### Status
@@ -21,20 +24,24 @@ The VM's operational status.
!!! tip
Additional statuses may be defined by setting `VirtualMachine.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Start on boot
### Start on Boot
The start on boot setting from the hypervisor.
!!! tip
Additional statuses may be defined by setting `VirtualMachine.start_on_boot` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Site & Cluster
### Site / Cluster / Device
The [site](../dcim/site.md) and/or [cluster](./cluster.md) to which the VM is assigned.
The location or host for this VM. At least one must be specified:
### Device
- **Site only**: The VM exists at a site but is not assigned to a specific cluster or device.
- **Cluster only**: The VM belongs to a virtualization cluster. The site is automatically inferred from the cluster's scope.
- **Device only**: The VM runs directly on a physical host device without a cluster (e.g. containers). The site is automatically inferred from the device's site.
- **Cluster + Device**: The VM belongs to a cluster and is pinned to a specific host device within that cluster. The device must be a registered host of the assigned cluster.
The physical host [device](../dcim/device.md) within the assigned site/cluster on which this VM resides.
!!! info "New in NetBox v4.6"
Virtual machines can now be assigned directly to a device without requiring a cluster. This is particularly useful for modeling VMs running on standalone hosts outside of a cluster.
### Platform
@@ -64,4 +71,7 @@ The amount of disk storage provisioned, in megabytes.
### Serial Number
Optional serial number assigned to this virtual machine. Unlike devices, uniqueness is not enforced for virtual machine serial numbers.
Optional serial number assigned to this virtual machine.
!!! info
Unlike devices, uniqueness is not enforced for virtual machine serial numbers.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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