diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md
index b903ea131..4ddffb99a 100644
--- a/docs/models/virtualization/virtualmachine.md
+++ b/docs/models/virtualization/virtualmachine.md
@@ -1,6 +1,6 @@
# Virtual Machines
-A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster, and may optionally be assigned to a particular host device within that cluster.
+A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster.
Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it:
diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md
index 6f07ea87d..63fd9731f 100644
--- a/docs/release-notes/version-3.3.md
+++ b/docs/release-notes/version-3.3.md
@@ -9,6 +9,7 @@
### Enhancements
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
+* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
@@ -30,3 +31,5 @@
* Added required `status` field (default value: `active`)
* virtualization.VirtualMachine
* Added `device` field
+ * The `site` field is now directly writable (rather than being inferred from the assigned cluster)
+ * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index ac8409e09..2831a452a 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -81,13 +81,19 @@
+
+ Site |
+
+ {{ object.site|linkify|placeholder }}
+ |
+
Cluster |
{% if object.cluster.group %}
{{ object.cluster.group|linkify }} /
{% endif %}
- {{ object.cluster|linkify }}
+ {{ object.cluster|linkify|placeholder }}
|
diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py
index 6157d342d..52ccd002d 100644
--- a/netbox/utilities/testing/utils.py
+++ b/netbox/utilities/testing/utils.py
@@ -34,11 +34,12 @@ def post_data(data):
return ret
-def create_test_device(name, **attrs):
+def create_test_device(name, site=None, **attrs):
"""
Convenience method for creating a Device (e.g. for component testing).
"""
- site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
+ if site is None:
+ site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)
devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index d12d9affd..bd01b5533 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -68,8 +68,8 @@ class ClusterSerializer(NetBoxModelSerializer):
class VirtualMachineSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
- site = NestedSiteSerializer(read_only=True)
- cluster = NestedClusterSerializer()
+ site = NestedSiteSerializer(required=False, allow_null=True)
+ cluster = NestedClusterSerializer(required=False, allow_null=True)
device = NestedDeviceSerializer(required=False, allow_null=True)
role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index d86241b4f..d2a90ae34 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet):
class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
- 'cluster__site', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
+ 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
)
filterset_class = filtersets.VirtualMachineFilterSet
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index 3e1d50da4..00d3e2313 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -162,37 +162,36 @@ class VirtualMachineFilterSet(
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
- field_name='cluster__site__region',
+ field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
- field_name='cluster__site__region',
+ field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
- field_name='cluster__site__group',
+ field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
- field_name='cluster__site__group',
+ field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
- field_name='cluster__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
- field_name='cluster__site__slug',
+ field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index 67126d6c7..88dee3978 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/netbox/virtualization/forms/bulk_edit.py
@@ -106,9 +106,16 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
initial='',
widget=StaticSelect(),
)
+ site = DynamicModelChoiceField(
+ queryset=Site.objects.all(),
+ required=False
+ )
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ }
)
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
@@ -153,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
model = VirtualMachine
fieldsets = (
- (None, ('cluster', 'device', 'status', 'role', 'tenant', 'platform')),
+ (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')),
('Resources', ('vcpus', 'memory', 'disk'))
)
nullable_fields = (
- 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+ 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
)
@@ -236,8 +243,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
# See 5643
if 'pk' in self.initial:
site = None
- interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
- 'virtual_machine__cluster__site'
+ interfaces = VMInterface.objects.filter(
+ pk__in=self.initial['pk']
+ ).prefetch_related(
+ 'virtual_machine__site'
)
# Check interface sites. First interface should set site, further interfaces will either continue the
diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py
index 41f9b3773..2d7ee52e2 100644
--- a/netbox/virtualization/forms/bulk_import.py
+++ b/netbox/virtualization/forms/bulk_import.py
@@ -71,9 +71,16 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
choices=VirtualMachineStatusChoices,
help_text='Operational status'
)
+ site = CSVModelChoiceField(
+ queryset=Site.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text='Assigned site'
+ )
cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
+ required=False,
help_text='Assigned cluster'
)
device = CSVModelChoiceField(
@@ -106,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
class Meta:
model = VirtualMachine
fields = (
- 'name', 'status', 'role', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+ 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
+ 'comments',
)
diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py
index dba12d64d..cfafd7e39 100644
--- a/netbox/virtualization/forms/models.py
+++ b/netbox/virtualization/forms/models.py
@@ -165,6 +165,9 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
class VirtualMachineForm(TenancyForm, NetBoxModelForm):
+ site = DynamicModelChoiceField(
+ queryset=Site.objects.all()
+ )
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
@@ -176,7 +179,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
query_params={
- 'group_id': '$cluster_group'
+ 'site_id': '$site',
+ 'group_id': '$cluster_group',
}
)
device = DynamicModelChoiceField(
@@ -204,7 +208,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
fieldsets = (
('Virtual Machine', ('name', 'role', 'status', 'tags')),
- ('Cluster', ('cluster_group', 'cluster', 'device')),
+ ('Cluster', ('site', 'cluster_group', 'cluster', 'device')),
('Tenancy', ('tenant_group', 'tenant')),
('Management', ('platform', 'primary_ip4', 'primary_ip6')),
('Resources', ('vcpus', 'memory', 'disk')),
@@ -214,8 +218,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
class Meta:
model = VirtualMachine
fields = [
- 'name', 'status', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform',
- 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
+ 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant',
+ 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags',
+ 'local_context_data',
]
help_texts = {
'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
diff --git a/netbox/virtualization/migrations/0031_virtualmachine_device.py b/netbox/virtualization/migrations/0031_virtualmachine_device.py
deleted file mode 100644
index 407d60e79..000000000
--- a/netbox/virtualization/migrations/0031_virtualmachine_device.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# Generated by Django 4.0.4 on 2022-05-25 19:30
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('dcim', '0153_created_datetimefield'),
- ('virtualization', '0030_cluster_status'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='virtualmachine',
- name='device',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'),
- ),
- ]
diff --git a/netbox/virtualization/migrations/0031_virtualmachine_site_device.py b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py
new file mode 100644
index 000000000..85ea24455
--- /dev/null
+++ b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py
@@ -0,0 +1,28 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0153_created_datetimefield'),
+ ('virtualization', '0030_cluster_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='site',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.site'),
+ ),
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='device',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'),
+ ),
+ migrations.AlterField(
+ model_name='virtualmachine',
+ name='cluster',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster'),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py
new file mode 100644
index 000000000..e9c52bfde
--- /dev/null
+++ b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py
@@ -0,0 +1,27 @@
+from django.db import migrations
+
+
+def update_virtualmachines_site(apps, schema_editor):
+ """
+ Automatically set the site for all virtual machines.
+ """
+ VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
+
+ virtual_machines = VirtualMachine.objects.filter(cluster__site__isnull=False)
+ for vm in virtual_machines:
+ vm.site = vm.cluster.site
+ VirtualMachine.objects.bulk_update(virtual_machines, ['site'])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0031_virtualmachine_site_device'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=update_virtualmachines_site,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 51dbc9f43..02560a962 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -195,10 +195,19 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
"""
A virtual machine which runs inside a Cluster.
"""
+ site = models.ForeignKey(
+ to='dcim.Site',
+ on_delete=models.PROTECT,
+ related_name='virtual_machines',
+ blank=True,
+ null=True
+ )
cluster = models.ForeignKey(
to='virtualization.Cluster',
on_delete=models.PROTECT,
- related_name='virtual_machines'
+ related_name='virtual_machines',
+ blank=True,
+ null=True
)
device = models.ForeignKey(
to='dcim.Device',
@@ -291,7 +300,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
objects = ConfigContextModelQuerySet.as_manager()
clone_fields = [
- 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
+ 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
]
class Meta:
@@ -323,6 +332,22 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
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': f'A virtual machine must be assigned to a site and/or cluster.'
+ })
+
+ # Validate site for cluster & device
+ if self.cluster and self.cluster.site != self.site:
+ raise ValidationError({
+ 'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).'
+ })
+ if self.device and self.device.site != self.site:
+ raise ValidationError({
+ 'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).'
+ })
+
# Validate assigned cluster device
if self.device and self.device not in self.cluster.devices.all():
raise ValidationError({
@@ -357,10 +382,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
else:
return None
- @property
- def site(self):
- return self.cluster.site
-
#
# Interfaces
diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py
index 80eb0b37f..0fe2571b1 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -30,6 +30,9 @@ class VirtualMachineTable(NetBoxTable):
linkify=True
)
status = columns.ChoiceFieldColumn()
+ site = tables.Column(
+ linkify=True
+ )
cluster = tables.Column(
linkify=True
)
@@ -59,11 +62,11 @@ class VirtualMachineTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VirtualMachine
fields = (
- 'pk', 'id', 'name', 'status', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
- 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory',
+ 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
- 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
+ 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
)
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index 887781e01..b2ae68860 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -2,6 +2,7 @@ from django.urls import reverse
from rest_framework import status
from dcim.choices import InterfaceModeChoices
+from dcim.models import Site
from ipam.models import VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.choices import *
@@ -146,39 +147,49 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
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 = (
+ 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)
+
clusters = (
- Cluster(name='Cluster 1', type=clustertype, group=clustergroup),
- Cluster(name='Cluster 2', type=clustertype, group=clustergroup),
+ Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup),
+ Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup),
+ Cluster(name='Cluster 3', type=clustertype),
)
Cluster.objects.bulk_create(clusters)
- device1 = create_test_device('device1')
- device1.cluster = clusters[0]
- device1.save()
- device2 = create_test_device('device2')
- device2.cluster = clusters[1]
- device2.save()
+ device1 = create_test_device('device1', site=sites[0], cluster=clusters[0])
+ device2 = create_test_device('device2', site=sites[1], cluster=clusters[1])
virtual_machines = (
- VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=device1, local_context_data={'A': 1}),
- VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}),
- VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}),
+ VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=device1, local_context_data={'A': 1}),
+ VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], local_context_data={'B': 2}),
+ VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], local_context_data={'C': 3}),
)
VirtualMachine.objects.bulk_create(virtual_machines)
cls.create_data = [
{
'name': 'Virtual Machine 4',
+ 'site': sites[1].pk,
'cluster': clusters[1].pk,
'device': device2.pk,
},
{
'name': 'Virtual Machine 5',
+ 'site': sites[1].pk,
'cluster': clusters[1].pk,
},
{
'name': 'Virtual Machine 6',
- 'cluster': clusters[1].pk,
+ 'site': sites[1].pk,
+ },
+ {
+ 'name': 'Virtual Machine 7',
+ 'cluster': clusters[2].pk,
},
]
diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py
index 3fd43d0c1..d3ff12887 100644
--- a/netbox/virtualization/tests/test_filtersets.py
+++ b/netbox/virtualization/tests/test_filtersets.py
@@ -274,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
vms = (
- VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
- VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
- VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
+ VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
+ VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
+ VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
)
VirtualMachine.objects.bulk_create(vms)
diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py
index 3b4d73a30..df5816efa 100644
--- a/netbox/virtualization/tests/test_models.py
+++ b/netbox/virtualization/tests/test_models.py
@@ -1,21 +1,19 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
+from dcim.models import Site
from virtualization.models import *
from tenancy.models import Tenant
class VirtualMachineTestCase(TestCase):
- def setUp(self):
-
- cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='Test Cluster Type 1')
- self.cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type)
-
def test_vm_duplicate_name_per_cluster(self):
+ cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+ cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type)
vm1 = VirtualMachine(
- cluster=self.cluster,
+ cluster=cluster,
name='Test VM 1'
)
vm1.save()
@@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase):
# Two VMs assigned to the same Cluster and different Tenants should pass validation
vm2.full_clean()
vm2.save()
+
+ def test_vm_mismatched_site_cluster(self):
+ cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+
+ 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, site=sites[0]),
+ Cluster(name='Cluster 2', type=cluster_type, site=sites[1]),
+ Cluster(name='Cluster 3', type=cluster_type, site=None),
+ )
+ Cluster.objects.bulk_create(clusters)
+
+ # VM with site only should pass
+ VirtualMachine(name='vm1', site=sites[0]).full_clean()
+
+ # VM with non-site cluster only should pass
+ VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()
+
+ # VM with mismatched site & cluster should fail
+ with self.assertRaises(ValidationError):
+ VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean()
+
+ # VM with cluster site but no direct site should fail
+ with self.assertRaises(ValidationError):
+ VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean()
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 4b1d64de5..01d4394f3 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -168,23 +168,29 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Platform.objects.bulk_create(platforms)
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
+
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clusters = (
- Cluster(name='Cluster 1', type=clustertype),
- Cluster(name='Cluster 2', type=clustertype),
+ Cluster(name='Cluster 1', type=clustertype, site=sites[0]),
+ Cluster(name='Cluster 2', type=clustertype, site=sites[1]),
)
Cluster.objects.bulk_create(clusters)
devices = (
- create_test_device('device1', cluster=clusters[0]),
- create_test_device('device2', cluster=clusters[1]),
+ create_test_device('device1', site=sites[0], cluster=clusters[0]),
+ create_test_device('device2', site=sites[1], cluster=clusters[1]),
)
VirtualMachine.objects.bulk_create([
- VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
- VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
- VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
+ VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
+ VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
+ VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -192,6 +198,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = {
'cluster': clusters[1].pk,
'device': devices[1].pk,
+ 'site': sites[1].pk,
'tenant': None,
'platform': platforms[1].pk,
'name': 'Virtual Machine X',
@@ -208,13 +215,14 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
- "name,status,cluster,device",
- "Virtual Machine 4,active,Cluster 1,device1",
- "Virtual Machine 5,active,Cluster 1,device1",
- "Virtual Machine 6,active,Cluster 1,",
+ "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,",
)
cls.bulk_edit_data = {
+ 'site': sites[1].pk,
'cluster': clusters[1].pk,
'device': devices[1].pk,
'tenant': None,
@@ -252,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site)
virtualmachines = (
- VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole),
- VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole),
+ VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=devicerole),
+ VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=devicerole),
)
VirtualMachine.objects.bulk_create(virtualmachines)