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 @@
Cluster
+ + + + 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)
Site + {{ object.site|linkify|placeholder }} +
Cluster {% if object.cluster.group %} {{ object.cluster.group|linkify }} / {% endif %} - {{ object.cluster|linkify }} + {{ object.cluster|linkify|placeholder }}