1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Closes #8222: Enable the assignment of a VM to a specific host device within a cluster

This commit is contained in:
jeremystretch
2022-05-25 16:01:10 -04:00
parent 31024ce672
commit b331f047af
17 changed files with 155 additions and 48 deletions

View File

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

View File

@ -9,6 +9,7 @@
### Enhancements
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
* [#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
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
@ -26,4 +27,6 @@
* The `nat_inside` field no longer requires a unique value
* The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
* virtualization.Cluster
* Add required `status` field (default value: `active`)
* Added required `status` field (default value: `active`)
* virtualization.VirtualMachine
* Added `device` field

View File

@ -78,9 +78,7 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Cluster
</h5>
<h5 class="card-header">Cluster</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
@ -96,13 +94,17 @@
<th scope="row">Cluster Type</th>
<td>{{ object.cluster.type }}</td>
</tr>
<tr>
<th scope="row">Device</th>
<td>
{{ object.device|linkify|placeholder }}
</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Resources
</h5>
<h5 class="card-header">Resources</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>

View File

@ -34,7 +34,7 @@ def post_data(data):
return ret
def create_test_device(name):
def create_test_device(name, **attrs):
"""
Convenience method for creating a Device (e.g. for component testing).
"""
@ -42,7 +42,7 @@ def create_test_device(name):
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')
device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole)
device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs)
return device

View File

@ -1,7 +1,9 @@
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
from dcim.api.nested_serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
)
from dcim.choices import InterfaceModeChoices
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
from ipam.models import VLAN
@ -68,6 +70,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
site = NestedSiteSerializer(read_only=True)
cluster = NestedClusterSerializer()
device = NestedDeviceSerializer(required=False, allow_null=True)
role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
platform = NestedPlatformSerializer(required=False, allow_null=True)
@ -78,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
class Meta:
model = VirtualMachine
fields = [
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
'tags', 'custom_fields', 'created', 'last_updated',
]
validators = []
@ -90,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class Meta(VirtualMachineSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
'custom_fields', 'config_context', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)

View File

@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet):
class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
'cluster__site', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
)
filterset_class = filtersets.VirtualMachineFilterSet

View File

@ -1,7 +1,7 @@
import django_filters
from django.db.models import Q
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
@ -150,6 +150,16 @@ class VirtualMachineFilterSet(
to_field_name='name',
label='Cluster',
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='cluster__site__region',

View File

@ -2,7 +2,7 @@ from django import forms
from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from ipam.models import VLAN, VRF
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
@ -110,6 +110,13 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
queryset=Cluster.objects.all(),
required=False
)
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'cluster_id': '$cluster'
}
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.filter(
vm_role=True
@ -146,11 +153,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
model = VirtualMachine
fieldsets = (
(None, ('cluster', 'status', 'role', 'tenant', 'platform')),
(None, ('cluster', 'device', 'status', 'role', 'tenant', 'platform')),
('Resources', ('vcpus', 'memory', 'disk'))
)
nullable_fields = (
'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
)

View File

@ -1,5 +1,5 @@
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site
from dcim.models import Device, DeviceRole, Platform, Site
from ipam.models import VRF
from netbox.forms import NetBoxModelCSVForm
from tenancy.models import Tenant
@ -76,6 +76,12 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
to_field_name='name',
help_text='Assigned cluster'
)
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
required=False,
help_text='Assigned device within cluster'
)
role = CSVModelChoiceField(
queryset=DeviceRole.objects.filter(
vm_role=True
@ -100,7 +106,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
class Meta:
model = VirtualMachine
fields = (
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
'name', 'status', 'role', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
)

View File

@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.forms import LocalConfigContextFilterForm
from ipam.models import VRF
from netbox.forms import NetBoxModelFilterSetForm
@ -87,7 +87,7 @@ class VirtualMachineFilterForm(
model = VirtualMachine
fieldsets = (
(None, ('q', 'tag')),
('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')),
('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
('Tenant', ('tenant_group_id', 'tenant_id')),
@ -110,6 +110,11 @@ class VirtualMachineFilterForm(
required=False,
label=_('Cluster')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
label=_('Device')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,

View File

@ -179,6 +179,13 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
'group_id': '$cluster_group'
}
)
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'cluster_id': '$cluster'
}
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
@ -197,7 +204,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
fieldsets = (
('Virtual Machine', ('name', 'role', 'status', 'tags')),
('Cluster', ('cluster_group', 'cluster')),
('Cluster', ('cluster_group', 'cluster', 'device')),
('Tenancy', ('tenant_group', 'tenant')),
('Management', ('platform', 'primary_ip4', 'primary_ip6')),
('Resources', ('vcpus', 'memory', 'disk')),
@ -207,8 +214,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
class Meta:
model = VirtualMachine
fields = [
'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
'name', 'status', '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 "

View File

@ -0,0 +1,20 @@
# 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'),
),
]

View File

@ -200,6 +200,13 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
on_delete=models.PROTECT,
related_name='virtual_machines'
)
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.PROTECT,
related_name='virtual_machines',
blank=True,
null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@ -316,6 +323,12 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
def clean(self):
super().clean()
# Validate assigned cluster device
if self.device and self.device not in self.cluster.devices.all():
raise ValidationError({
'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).'
})
# Validate primary IP addresses
interfaces = self.interfaces.all()
for field in ['primary_ip4', 'primary_ip6']:

View File

@ -33,6 +33,9 @@ class VirtualMachineTable(NetBoxTable):
cluster = tables.Column(
linkify=True
)
device = tables.Column(
linkify=True
)
role = columns.ColoredLabelColumn()
tenant = TenantColumn()
comments = columns.MarkdownColumn()
@ -56,7 +59,7 @@ class VirtualMachineTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VirtualMachine
fields = (
'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
'pk', 'id', 'name', 'status', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (

View File

@ -3,7 +3,7 @@ from rest_framework import status
from dcim.choices import InterfaceModeChoices
from ipam.models import VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -152,8 +152,15 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
)
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()
virtual_machines = (
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}),
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}),
)
@ -163,6 +170,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Virtual Machine 4',
'cluster': clusters[1].pk,
'device': device2.pk,
},
{
'name': 'Virtual Machine 5',

View File

@ -1,9 +1,9 @@
from django.test import TestCase
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from ipam.models import IPAddress, VRF
from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.choices import *
from virtualization.filtersets import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -225,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
site_group.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]),
)
Site.objects.bulk_create(sites)
@ -252,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
)
DeviceRole.objects.bulk_create(roles)
devices = (
create_test_device('device1', cluster=clusters[0]),
create_test_device('device2', cluster=clusters[1]),
create_test_device('device3', cluster=clusters[2]),
)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
@ -268,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
vms = (
VirtualMachine(name='Virtual Machine 1', cluster=clusters[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], 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], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
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.objects.bulk_create(vms)
@ -331,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'cluster': [clusters[0].name, clusters[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}

View File

@ -5,7 +5,7 @@ from netaddr import EUI
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site
from ipam.models import VLAN, VRF
from utilities.testing import ViewTestCases, create_tags
from utilities.testing import ViewTestCases, create_tags, create_test_device
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -176,16 +176,22 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Cluster.objects.bulk_create(clusters)
devices = (
create_test_device('device1', cluster=clusters[0]),
create_test_device('device2', cluster=clusters[1]),
)
VirtualMachine.objects.bulk_create([
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
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]),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'cluster': clusters[1].pk,
'device': devices[1].pk,
'tenant': None,
'platform': platforms[1].pk,
'name': 'Virtual Machine X',
@ -202,14 +208,15 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"name,status,cluster",
"Virtual Machine 4,active,Cluster 1",
"Virtual Machine 5,active,Cluster 1",
"Virtual Machine 6,active,Cluster 1",
"name,status,cluster,device",
"Virtual Machine 4,active,Cluster 1,device1",
"Virtual Machine 5,active,Cluster 1,device1",
"Virtual Machine 6,active,Cluster 1,",
)
cls.bulk_edit_data = {
'cluster': clusters[1].pk,
'device': devices[1].pk,
'tenant': None,
'platform': platforms[1].pk,
'status': VirtualMachineStatusChoices.STATUS_STAGED,