Просмотр исходного кода

Closes #5303: A virtual machine may be assigned to a site and/or cluster

jeremystretch 3 лет назад
Родитель
Сommit
db42589cca

+ 1 - 1
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:
 

+ 3 - 0
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.

+ 7 - 1
netbox/templates/virtualization/virtualmachine.html

@@ -81,13 +81,19 @@
             <h5 class="card-header">Cluster</h5>
             <div class="card-body">
                 <table class="table table-hover attr-table">
+                    <tr>
+                        <th scope="row">Site</th>
+                        <td>
+                            {{ object.site|linkify|placeholder }}
+                        </td>
+                    </tr>
                     <tr>
                         <th scope="row">Cluster</th>
                         <td>
                             {% if object.cluster.group %}
                                 {{ object.cluster.group|linkify }} /
                             {% endif %}
-                            {{ object.cluster|linkify }}
+                            {{ object.cluster|linkify|placeholder }}
                         </td>
                     </tr>
                     <tr>

+ 3 - 2
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')

+ 2 - 2
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)

+ 1 - 1
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
 

+ 5 - 6
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)',

+ 14 - 5
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

+ 9 - 1
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',
         )
 
 

+ 9 - 4
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 "

+ 0 - 20
netbox/virtualization/migrations/0031_virtualmachine_device.py

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

+ 28 - 0
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'),
+        ),
+    ]

+ 27 - 0
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
+        ),
+    ]

+ 27 - 6
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

+ 6 - 3
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',
         )
 
 

+ 23 - 12
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,
             },
         ]
 

+ 3 - 3
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)
 

+ 34 - 6
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()

+ 21 - 13
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)