1from django.conf import settings 2from django.contrib.contenttypes.fields import GenericRelation 3from django.core.exceptions import ValidationError 4from django.core.validators import MinValueValidator 5from django.db import models 6from django.urls import reverse 7 8from dcim.models import BaseInterface, Device 9from extras.models import ConfigContextModel 10from extras.querysets import ConfigContextModelQuerySet 11from extras.utils import extras_features 12from netbox.models import OrganizationalModel, PrimaryModel 13from utilities.fields import NaturalOrderingField 14from utilities.ordering import naturalize_interface 15from utilities.query_functions import CollateAsChar 16from utilities.querysets import RestrictedQuerySet 17from .choices import * 18 19 20__all__ = ( 21 'Cluster', 22 'ClusterGroup', 23 'ClusterType', 24 'VirtualMachine', 25 'VMInterface', 26) 27 28 29# 30# Cluster types 31# 32 33@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') 34class ClusterType(OrganizationalModel): 35 """ 36 A type of Cluster. 37 """ 38 name = models.CharField( 39 max_length=100, 40 unique=True 41 ) 42 slug = models.SlugField( 43 max_length=100, 44 unique=True 45 ) 46 description = models.CharField( 47 max_length=200, 48 blank=True 49 ) 50 51 objects = RestrictedQuerySet.as_manager() 52 53 class Meta: 54 ordering = ['name'] 55 56 def __str__(self): 57 return self.name 58 59 def get_absolute_url(self): 60 return reverse('virtualization:clustertype', args=[self.pk]) 61 62 63# 64# Cluster groups 65# 66 67@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') 68class ClusterGroup(OrganizationalModel): 69 """ 70 An organizational group of Clusters. 71 """ 72 name = models.CharField( 73 max_length=100, 74 unique=True 75 ) 76 slug = models.SlugField( 77 max_length=100, 78 unique=True 79 ) 80 description = models.CharField( 81 max_length=200, 82 blank=True 83 ) 84 vlan_groups = GenericRelation( 85 to='ipam.VLANGroup', 86 content_type_field='scope_type', 87 object_id_field='scope_id', 88 related_query_name='cluster_group' 89 ) 90 91 objects = RestrictedQuerySet.as_manager() 92 93 class Meta: 94 ordering = ['name'] 95 96 def __str__(self): 97 return self.name 98 99 def get_absolute_url(self): 100 return reverse('virtualization:clustergroup', args=[self.pk]) 101 102 103# 104# Clusters 105# 106 107@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') 108class Cluster(PrimaryModel): 109 """ 110 A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. 111 """ 112 name = models.CharField( 113 max_length=100, 114 unique=True 115 ) 116 type = models.ForeignKey( 117 to=ClusterType, 118 on_delete=models.PROTECT, 119 related_name='clusters' 120 ) 121 group = models.ForeignKey( 122 to=ClusterGroup, 123 on_delete=models.PROTECT, 124 related_name='clusters', 125 blank=True, 126 null=True 127 ) 128 tenant = models.ForeignKey( 129 to='tenancy.Tenant', 130 on_delete=models.PROTECT, 131 related_name='clusters', 132 blank=True, 133 null=True 134 ) 135 site = models.ForeignKey( 136 to='dcim.Site', 137 on_delete=models.PROTECT, 138 related_name='clusters', 139 blank=True, 140 null=True 141 ) 142 comments = models.TextField( 143 blank=True 144 ) 145 vlan_groups = GenericRelation( 146 to='ipam.VLANGroup', 147 content_type_field='scope_type', 148 object_id_field='scope_id', 149 related_query_name='cluster' 150 ) 151 152 objects = RestrictedQuerySet.as_manager() 153 154 clone_fields = [ 155 'type', 'group', 'tenant', 'site', 156 ] 157 158 class Meta: 159 ordering = ['name'] 160 161 def __str__(self): 162 return self.name 163 164 def get_absolute_url(self): 165 return reverse('virtualization:cluster', args=[self.pk]) 166 167 def clean(self): 168 super().clean() 169 170 # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. 171 if self.pk and self.site: 172 nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() 173 if nonsite_devices: 174 raise ValidationError({ 175 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format( 176 nonsite_devices, self.site 177 ) 178 }) 179 180 181# 182# Virtual machines 183# 184 185@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') 186class VirtualMachine(PrimaryModel, ConfigContextModel): 187 """ 188 A virtual machine which runs inside a Cluster. 189 """ 190 cluster = models.ForeignKey( 191 to='virtualization.Cluster', 192 on_delete=models.PROTECT, 193 related_name='virtual_machines' 194 ) 195 tenant = models.ForeignKey( 196 to='tenancy.Tenant', 197 on_delete=models.PROTECT, 198 related_name='virtual_machines', 199 blank=True, 200 null=True 201 ) 202 platform = models.ForeignKey( 203 to='dcim.Platform', 204 on_delete=models.SET_NULL, 205 related_name='virtual_machines', 206 blank=True, 207 null=True 208 ) 209 name = models.CharField( 210 max_length=64 211 ) 212 _name = NaturalOrderingField( 213 target_field='name', 214 max_length=100, 215 blank=True 216 ) 217 status = models.CharField( 218 max_length=50, 219 choices=VirtualMachineStatusChoices, 220 default=VirtualMachineStatusChoices.STATUS_ACTIVE, 221 verbose_name='Status' 222 ) 223 role = models.ForeignKey( 224 to='dcim.DeviceRole', 225 on_delete=models.PROTECT, 226 related_name='virtual_machines', 227 limit_choices_to={'vm_role': True}, 228 blank=True, 229 null=True 230 ) 231 primary_ip4 = models.OneToOneField( 232 to='ipam.IPAddress', 233 on_delete=models.SET_NULL, 234 related_name='+', 235 blank=True, 236 null=True, 237 verbose_name='Primary IPv4' 238 ) 239 primary_ip6 = models.OneToOneField( 240 to='ipam.IPAddress', 241 on_delete=models.SET_NULL, 242 related_name='+', 243 blank=True, 244 null=True, 245 verbose_name='Primary IPv6' 246 ) 247 vcpus = models.DecimalField( 248 max_digits=6, 249 decimal_places=2, 250 blank=True, 251 null=True, 252 verbose_name='vCPUs', 253 validators=( 254 MinValueValidator(0.01), 255 ) 256 ) 257 memory = models.PositiveIntegerField( 258 blank=True, 259 null=True, 260 verbose_name='Memory (MB)' 261 ) 262 disk = models.PositiveIntegerField( 263 blank=True, 264 null=True, 265 verbose_name='Disk (GB)' 266 ) 267 comments = models.TextField( 268 blank=True 269 ) 270 271 objects = ConfigContextModelQuerySet.as_manager() 272 273 clone_fields = [ 274 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', 275 ] 276 277 class Meta: 278 ordering = ('_name', 'pk') # Name may be non-unique 279 unique_together = [ 280 ['cluster', 'tenant', 'name'] 281 ] 282 283 def __str__(self): 284 return self.name 285 286 def get_absolute_url(self): 287 return reverse('virtualization:virtualmachine', args=[self.pk]) 288 289 def validate_unique(self, exclude=None): 290 291 # Check for a duplicate name on a VM assigned to the same Cluster and no Tenant. This is necessary 292 # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation 293 # of the uniqueness constraint without manual intervention. 294 if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter( 295 name=self.name, cluster=self.cluster, tenant__isnull=True 296 ): 297 raise ValidationError({ 298 'name': 'A virtual machine with this name already exists in the assigned cluster.' 299 }) 300 301 super().validate_unique(exclude) 302 303 def clean(self): 304 super().clean() 305 306 # Validate primary IP addresses 307 interfaces = self.interfaces.all() 308 for field in ['primary_ip4', 'primary_ip6']: 309 ip = getattr(self, field) 310 if ip is not None: 311 if ip.assigned_object in interfaces: 312 pass 313 elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces: 314 pass 315 else: 316 raise ValidationError({ 317 field: f"The specified IP address ({ip}) is not assigned to this VM.", 318 }) 319 320 def get_status_class(self): 321 return VirtualMachineStatusChoices.CSS_CLASSES.get(self.status) 322 323 @property 324 def primary_ip(self): 325 if settings.PREFER_IPV4 and self.primary_ip4: 326 return self.primary_ip4 327 elif self.primary_ip6: 328 return self.primary_ip6 329 elif self.primary_ip4: 330 return self.primary_ip4 331 else: 332 return None 333 334 @property 335 def site(self): 336 return self.cluster.site 337 338 339# 340# Interfaces 341# 342 343@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') 344class VMInterface(PrimaryModel, BaseInterface): 345 virtual_machine = models.ForeignKey( 346 to='virtualization.VirtualMachine', 347 on_delete=models.CASCADE, 348 related_name='interfaces' 349 ) 350 name = models.CharField( 351 max_length=64 352 ) 353 _name = NaturalOrderingField( 354 target_field='name', 355 naturalize_function=naturalize_interface, 356 max_length=100, 357 blank=True 358 ) 359 description = models.CharField( 360 max_length=200, 361 blank=True 362 ) 363 parent = models.ForeignKey( 364 to='self', 365 on_delete=models.SET_NULL, 366 related_name='child_interfaces', 367 null=True, 368 blank=True, 369 verbose_name='Parent interface' 370 ) 371 untagged_vlan = models.ForeignKey( 372 to='ipam.VLAN', 373 on_delete=models.SET_NULL, 374 related_name='vminterfaces_as_untagged', 375 null=True, 376 blank=True, 377 verbose_name='Untagged VLAN' 378 ) 379 tagged_vlans = models.ManyToManyField( 380 to='ipam.VLAN', 381 related_name='vminterfaces_as_tagged', 382 blank=True, 383 verbose_name='Tagged VLANs' 384 ) 385 ip_addresses = GenericRelation( 386 to='ipam.IPAddress', 387 content_type_field='assigned_object_type', 388 object_id_field='assigned_object_id', 389 related_query_name='vminterface' 390 ) 391 392 objects = RestrictedQuerySet.as_manager() 393 394 class Meta: 395 verbose_name = 'interface' 396 ordering = ('virtual_machine', CollateAsChar('_name')) 397 unique_together = ('virtual_machine', 'name') 398 399 def __str__(self): 400 return self.name 401 402 def get_absolute_url(self): 403 return reverse('virtualization:vminterface', kwargs={'pk': self.pk}) 404 405 def clean(self): 406 super().clean() 407 408 # An interface's parent must belong to the same virtual machine 409 if self.parent and self.parent.virtual_machine != self.virtual_machine: 410 raise ValidationError({ 411 'parent': f"The selected parent interface ({self.parent}) belongs to a different virtual machine " 412 f"({self.parent.virtual_machine})." 413 }) 414 415 # An interface cannot be its own parent 416 if self.pk and self.parent_id == self.pk: 417 raise ValidationError({'parent': "An interface cannot be its own parent."}) 418 419 # Validate untagged VLAN 420 if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: 421 raise ValidationError({ 422 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the " 423 f"interface's parent virtual machine, or it must be global" 424 }) 425 426 def to_objectchange(self, action): 427 # Annotate the parent VirtualMachine 428 return super().to_objectchange(action, related_object=self.virtual_machine) 429 430 @property 431 def parent_object(self): 432 return self.virtual_machine 433