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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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 = (

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

@ -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]}

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