from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models from django.db.models import Q from django.db.models.functions import Lower from django.urls import reverse from django.utils.translation import gettext_lazy as _ from dcim.models import BaseInterface from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config from netbox.models import NetBoxModel, PrimaryModel from netbox.models.features import ContactsMixin from utilities.fields import CounterCacheField, NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar from utilities.tracking import TrackingModelMixin from virtualization.choices import * __all__ = ( 'VirtualMachine', 'VMInterface', ) class VirtualMachine(ContactsMixin, PrimaryModel, 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', blank=True, null=True ) 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, related_name='virtual_machines', blank=True, null=True ) platform = models.ForeignKey( to='dcim.Platform', on_delete=models.SET_NULL, related_name='virtual_machines', blank=True, null=True ) name = models.CharField( verbose_name=_('name'), max_length=64 ) _name = NaturalOrderingField( target_field='name', max_length=100, blank=True ) status = models.CharField( max_length=50, choices=VirtualMachineStatusChoices, default=VirtualMachineStatusChoices.STATUS_ACTIVE, verbose_name=_('status') ) role = models.ForeignKey( to='dcim.DeviceRole', on_delete=models.PROTECT, related_name='virtual_machines', limit_choices_to={'vm_role': True}, blank=True, null=True ) primary_ip4 = models.OneToOneField( to='ipam.IPAddress', on_delete=models.SET_NULL, related_name='+', blank=True, null=True, verbose_name=_('primary IPv4') ) primary_ip6 = models.OneToOneField( to='ipam.IPAddress', on_delete=models.SET_NULL, related_name='+', blank=True, null=True, verbose_name=_('primary IPv6') ) vcpus = models.DecimalField( max_digits=6, decimal_places=2, blank=True, null=True, verbose_name=_('vCPUs'), validators=( MinValueValidator(0.01), ) ) memory = models.PositiveIntegerField( blank=True, null=True, verbose_name=_('memory (MB)') ) disk = models.PositiveIntegerField( blank=True, null=True, verbose_name=_('disk (GB)') ) # Counter fields interface_count = CounterCacheField( to_model='virtualization.VMInterface', to_field='virtual_machine' ) objects = ConfigContextModelQuerySet.as_manager() clone_fields = ( 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', ) prerequisite_models = ( 'virtualization.Cluster', ) class Meta: ordering = ('_name', 'pk') # Name may be non-unique constraints = ( models.UniqueConstraint( Lower('name'), 'cluster', 'tenant', name='%(app_label)s_%(class)s_unique_name_cluster_tenant' ), models.UniqueConstraint( Lower('name'), 'cluster', name='%(app_label)s_%(class)s_unique_name_cluster', condition=Q(tenant__isnull=True), violation_error_message=_("Virtual machine name must be unique per cluster.") ), ) verbose_name = _('virtual machine') verbose_name_plural = _('virtual machines') def __str__(self): return self.name def get_absolute_url(self): return reverse('virtualization:virtualmachine', args=[self.pk]) 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': _('A virtual machine must be assigned to a site and/or cluster.') }) # Validate site for cluster & device if self.cluster and self.site and self.cluster.site != self.site: raise ValidationError({ 'cluster': _( 'The selected cluster ({cluster}) is not assigned to this site ({site}).' ).format(cluster=self.cluster, site=self.site) }) # Validate assigned cluster device if self.device and not self.cluster: raise ValidationError({ 'device': _('Must specify a cluster when assigning a host device.') }) if self.device and self.device not in self.cluster.devices.all(): raise ValidationError({ 'device': _( "The selected device ({device}) is not assigned to this cluster ({cluster})." ).format(device=self.device, cluster=self.cluster) }) # Validate primary IP addresses interfaces = self.interfaces.all() if self.pk else None for family in (4, 6): field = f'primary_ip{family}' ip = getattr(self, field) if ip is not None: if ip.address.version != family: raise ValidationError({ field: _( "Must be an IPv{family} address. ({ip} is an IPv{version} address.)" ).format(family=family, ip=ip, version=ip.address.version) }) if ip.assigned_object in interfaces: pass elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces: pass else: raise ValidationError({ field: _("The specified IP address ({ip}) is not assigned to this VM.").format(ip=ip), }) def save(self, *args, **kwargs): # Assign site from cluster if not set if self.cluster and not self.site: self.site = self.cluster.site super().save(*args, **kwargs) def get_status_color(self): return VirtualMachineStatusChoices.colors.get(self.status) @property def primary_ip(self): if get_config().PREFER_IPV4 and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 elif self.primary_ip4: return self.primary_ip4 else: return None class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, related_name='interfaces' ) name = models.CharField( verbose_name=_('name'), max_length=64 ) _name = NaturalOrderingField( target_field='name', naturalize_function=naturalize_interface, max_length=100, blank=True ) description = models.CharField( verbose_name=_('description'), max_length=200, blank=True ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, related_name='vminterfaces_as_untagged', null=True, blank=True, verbose_name=_('untagged VLAN') ) tagged_vlans = models.ManyToManyField( to='ipam.VLAN', related_name='vminterfaces_as_tagged', blank=True, verbose_name=_('tagged VLANs') ) ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', object_id_field='assigned_object_id', related_query_name='vminterface' ) vrf = models.ForeignKey( to='ipam.VRF', on_delete=models.SET_NULL, related_name='vminterfaces', null=True, blank=True, verbose_name=_('VRF') ) fhrp_group_assignments = GenericRelation( to='ipam.FHRPGroupAssignment', content_type_field='interface_type', object_id_field='interface_id', related_query_name='+' ) l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', content_type_field='assigned_object_type', object_id_field='assigned_object_id', related_query_name='vminterface', ) class Meta: ordering = ('virtual_machine', CollateAsChar('_name')) constraints = ( models.UniqueConstraint( fields=('virtual_machine', 'name'), name='%(app_label)s_%(class)s_unique_virtual_machine_name' ), ) verbose_name = _('interface') verbose_name_plural = _('interfaces') def __str__(self): return self.name def get_absolute_url(self): return reverse('virtualization:vminterface', kwargs={'pk': self.pk}) def clean(self): super().clean() # Parent validation # An interface cannot be its own parent if self.pk and self.parent_id == self.pk: raise ValidationError({'parent': _("An interface cannot be its own parent.")}) # An interface's parent must belong to the same virtual machine if self.parent and self.parent.virtual_machine != self.virtual_machine: raise ValidationError({ 'parent': _( "The selected parent interface ({parent}) belongs to a different virtual machine " "({virtual_machine})." ).format(parent=self.parent, virtual_machine=self.parent.virtual_machine) }) # Bridge validation # An interface cannot be bridged to itself if self.pk and self.bridge_id == self.pk: raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")}) # A bridged interface belong to the same virtual machine if self.bridge and self.bridge.virtual_machine != self.virtual_machine: raise ValidationError({ 'bridge': _( "The selected bridge interface ({bridge}) belongs to a different virtual machine " "({virtual_machine})." ).format(bridge=self.bridge, virtual_machine=self.bridge.virtual_machine) }) # VLAN validation # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: raise ValidationError({ 'untagged_vlan': _( "The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent " "virtual machine, or it must be global." ).format(untagged_vlan=self.untagged_vlan) }) def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.virtual_machine return objectchange @property def parent_object(self): return self.virtual_machine @property def l2vpn_termination(self): return self.l2vpn_terminations.first()