Coverage for virtualization/models/virtualmachines.py: 47%
181 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import decimal
3from django.contrib.contenttypes.fields import GenericRelation
4from django.core.exceptions import ValidationError
5from django.core.validators import MinValueValidator
6from django.db import models
7from django.db.models import Q, Sum
8from django.db.models.functions import Lower
9from django.utils.translation import gettext_lazy as _
11from dcim.models import BaseInterface
12from dcim.models.mixins import RenderConfigMixin
13from extras.models import ConfigContextModel
14from extras.querysets import ConfigContextModelQuerySet
15from netbox.config import get_config
16from netbox.models import NetBoxModel, PrimaryModel
17from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
18from netbox.models.mixins import OwnerMixin
19from utilities.fields import CounterCacheField, NaturalOrderingField
20from utilities.ordering import naturalize_interface
21from utilities.query_functions import CollateAsChar
22from utilities.tracking import TrackingModelMixin
24from ..choices import *
26__all__ = (
27 'VMInterface',
28 'VirtualDisk',
29 'VirtualMachine',
30 'VirtualMachineType',
31)
34class VirtualMachineType(ImageAttachmentsMixin, PrimaryModel):
35 """
36 A type defining default attributes (platform, vCPUs, memory, etc.) for virtual machines.
37 """
39 name = models.CharField(
40 verbose_name=_('name'),
41 max_length=100,
42 )
43 slug = models.SlugField(
44 verbose_name=_('slug'),
45 max_length=100,
46 unique=True,
47 )
48 default_platform = models.ForeignKey(
49 to='dcim.Platform',
50 on_delete=models.SET_NULL,
51 related_name='+',
52 blank=True,
53 null=True,
54 verbose_name=_('default platform'),
55 )
56 default_vcpus = models.DecimalField(
57 verbose_name=_('default vCPUs'),
58 max_digits=6,
59 decimal_places=2,
60 blank=True,
61 null=True,
62 validators=(MinValueValidator(decimal.Decimal('0.01')),),
63 )
64 default_memory = models.PositiveIntegerField(
65 verbose_name=_('default memory'),
66 blank=True,
67 null=True,
68 )
70 # Counter fields
71 virtual_machine_count = CounterCacheField(
72 to_model='virtualization.VirtualMachine',
73 to_field='virtual_machine_type',
74 )
76 clone_fields = (
77 'default_platform',
78 'default_vcpus',
79 'default_memory',
80 )
82 class Meta:
83 ordering = ('name',)
84 constraints = (
85 models.UniqueConstraint(
86 Lower('name'),
87 name='%(app_label)s_%(class)s_unique_name',
88 violation_error_message=_('Virtual machine type name must be unique.'),
89 ),
90 )
91 indexes = (
92 models.Index(fields=('name',)), # Default ordering
93 )
94 verbose_name = _('virtual machine type')
95 verbose_name_plural = _('virtual machine types')
97 def __str__(self):
98 return self.name
101class VirtualMachine(
102 ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, TrackingModelMixin, PrimaryModel
103):
104 """
105 A virtual machine which runs on a Cluster or a standalone Device.
107 Each VM must be placed in at least one of three ways:
109 1. Assigned to a Site alone (e.g. for logical grouping without a specific host).
110 2. Assigned to a Cluster and optionally pinned to a host Device within that cluster.
111 3. Assigned directly to a standalone Device (one that does not belong to any cluster).
113 When a Cluster or Device is set, the Site is automatically inherited if not explicitly provided.
114 If a Device belongs to a Cluster, the Cluster must also be specified on the VM.
115 """
117 virtual_machine_type = models.ForeignKey(
118 to='virtualization.VirtualMachineType',
119 on_delete=models.PROTECT,
120 related_name='instances',
121 verbose_name=_('type'),
122 blank=True,
123 null=True,
124 )
125 site = models.ForeignKey(
126 to='dcim.Site',
127 on_delete=models.PROTECT,
128 related_name='virtual_machines',
129 blank=True,
130 null=True
131 )
132 cluster = models.ForeignKey(
133 to='virtualization.Cluster',
134 on_delete=models.PROTECT,
135 related_name='virtual_machines',
136 blank=True,
137 null=True
138 )
139 device = models.ForeignKey(
140 to='dcim.Device',
141 on_delete=models.PROTECT,
142 related_name='virtual_machines',
143 blank=True,
144 null=True
145 )
146 tenant = models.ForeignKey(
147 to='tenancy.Tenant',
148 on_delete=models.PROTECT,
149 related_name='virtual_machines',
150 blank=True,
151 null=True
152 )
153 platform = models.ForeignKey(
154 to='dcim.Platform',
155 on_delete=models.SET_NULL,
156 related_name='virtual_machines',
157 blank=True,
158 null=True
159 )
160 name = models.CharField(
161 verbose_name=_('name'),
162 max_length=64,
163 db_collation="natural_sort"
164 )
165 status = models.CharField(
166 max_length=50,
167 choices=VirtualMachineStatusChoices,
168 default=VirtualMachineStatusChoices.STATUS_ACTIVE,
169 verbose_name=_('status')
170 )
171 start_on_boot = models.CharField(
172 max_length=32,
173 choices=VirtualMachineStartOnBootChoices,
174 default=VirtualMachineStartOnBootChoices.STATUS_OFF,
175 verbose_name=_('start on boot'),
176 )
177 role = models.ForeignKey(
178 to='dcim.DeviceRole',
179 on_delete=models.PROTECT,
180 related_name='virtual_machines',
181 blank=True,
182 null=True
183 )
184 primary_ip4 = models.OneToOneField(
185 to='ipam.IPAddress',
186 on_delete=models.SET_NULL,
187 related_name='+',
188 blank=True,
189 null=True,
190 verbose_name=_('primary IPv4')
191 )
192 primary_ip6 = models.OneToOneField(
193 to='ipam.IPAddress',
194 on_delete=models.SET_NULL,
195 related_name='+',
196 blank=True,
197 null=True,
198 verbose_name=_('primary IPv6')
199 )
200 vcpus = models.DecimalField(
201 max_digits=6,
202 decimal_places=2,
203 blank=True,
204 null=True,
205 verbose_name=_('vCPUs'),
206 validators=(
207 MinValueValidator(decimal.Decimal(0.01)),
208 )
209 )
210 memory = models.PositiveIntegerField(
211 blank=True,
212 null=True,
213 verbose_name=_('memory')
214 )
215 disk = models.PositiveIntegerField(
216 blank=True,
217 null=True,
218 verbose_name=_('disk')
219 )
220 serial = models.CharField(
221 verbose_name=_('serial number'),
222 blank=True,
223 max_length=50
224 )
225 services = GenericRelation(
226 to='ipam.Service',
227 content_type_field='parent_object_type',
228 object_id_field='parent_object_id',
229 related_query_name='virtual_machine',
230 )
232 # Counter fields
233 interface_count = CounterCacheField(
234 to_model='virtualization.VMInterface',
235 to_field='virtual_machine'
236 )
237 virtual_disk_count = CounterCacheField(
238 to_model='virtualization.VirtualDisk',
239 to_field='virtual_machine'
240 )
242 objects = ConfigContextModelQuerySet.as_manager()
244 clone_fields = (
245 'virtual_machine_type', 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory',
246 'disk',
247 )
249 class Meta:
250 ordering = ('name', 'pk') # Name may be non-unique
251 indexes = (
252 models.Index(fields=('name', 'id')), # Default ordering
253 # Partial index supporting the background renderer's scan for objects whose config
254 # context cache has been invalidated (see extras.jobs.RenderConfigContextJob and
255 # extras.cache). Indexing only the NULL-cache rows keeps that lookup cheap on large
256 # tables where the steady state is for nearly every row to be populated.
257 models.Index(
258 fields=('id',),
259 condition=Q(_config_context_data__isnull=True),
260 name='virtualization_vm_cc_null',
261 ),
262 )
263 constraints = (
264 models.UniqueConstraint(
265 Lower('name'), 'cluster', 'tenant',
266 name='%(app_label)s_%(class)s_unique_name_cluster_tenant',
267 condition=Q(cluster__isnull=False),
268 nulls_distinct=False,
269 violation_error_message=_('Virtual machine name must be unique per cluster and tenant.')
270 ),
271 models.UniqueConstraint(
272 Lower('name'), 'device', 'tenant',
273 name='%(app_label)s_%(class)s_unique_name_device_tenant',
274 condition=Q(cluster__isnull=True, device__isnull=False),
275 nulls_distinct=False,
276 violation_error_message=_('Virtual machine name must be unique per device and tenant.')
277 ),
278 )
279 verbose_name = _('virtual machine')
280 verbose_name_plural = _('virtual machines')
281 permissions = [
282 ('render_config', 'Render configuration'),
283 ]
285 def __str__(self):
286 return self.name
288 def clean(self):
289 super().clean()
291 # Must be assigned to a site, cluster, and/or device
292 if not self.site and not self.cluster and not self.device: 292 ↛ 298line 292 didn't jump to line 298 because the condition on line 292 was always true
293 raise ValidationError(
294 _('A virtual machine must be assigned to a site, cluster, or device.')
295 )
297 # Validate site for cluster & VM
298 if self.cluster and self.site and self.cluster._site and self.cluster._site != self.site:
299 raise ValidationError({
300 'cluster': _(
301 'The selected cluster ({cluster}) is not assigned to this site ({site}).'
302 ).format(cluster=self.cluster, site=self.site)
303 })
305 # Validate site for the device & VM (when the device is standalone)
306 if self.device and self.site and self.device.site and self.device.site != self.site:
307 raise ValidationError({
308 'site': _(
309 'The selected device ({device}) is not assigned to this site ({site}).'
310 ).format(device=self.device, site=self.site)
311 })
313 # Direct device assignment is only for standalone hosts. If the selected
314 # device already belongs to a cluster, require that cluster explicitly.
315 if self.device and not self.cluster and self.device.cluster:
316 raise ValidationError({
317 'cluster': _(
318 "Must specify the assigned device's cluster ({cluster}) when assigning host device {device}."
319 ).format(cluster=self.device.cluster, device=self.device)
320 })
322 # Validate assigned cluster device
323 if self.device and self.cluster and self.device.cluster_id != self.cluster_id:
324 raise ValidationError({
325 'device': _(
326 "The selected device ({device}) is not assigned to this cluster ({cluster})."
327 ).format(device=self.device, cluster=self.cluster)
328 })
330 # Validate aggregate disk size
331 if not self._state.adding:
332 total_disk = self.virtualdisks.aggregate(Sum('size', default=0))['size__sum']
333 if total_disk and self.disk is None:
334 self.disk = total_disk
335 elif total_disk and self.disk != total_disk:
336 raise ValidationError({
337 'disk': _(
338 "The specified disk size ({size}) must match the aggregate size of assigned virtual disks "
339 "({total_size})."
340 ).format(size=self.disk, total_size=total_disk)
341 })
343 # Validate primary IP addresses
344 interfaces = self.interfaces.all() if self.pk else None
345 for family in (4, 6):
346 field = f'primary_ip{family}'
347 ip = getattr(self, field)
348 if ip is not None:
349 if ip.address.version != family:
350 raise ValidationError({
351 field: _(
352 "Must be an IPv{family} address. ({ip} is an IPv{version} address.)"
353 ).format(family=family, ip=ip, version=ip.address.version)
354 })
355 if ip.assigned_object in interfaces:
356 pass
357 elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces:
358 pass
359 else:
360 raise ValidationError({
361 field: _("The specified IP address ({ip}) is not assigned to this VM.").format(ip=ip),
362 })
364 def save(self, *args, **kwargs):
365 # Assign a site from a cluster or device if not set
366 if not self.site:
367 if self.cluster and self.cluster._site:
368 self.site = self.cluster._site
369 elif self.device and self.device.site:
370 self.site = self.device.site
372 if self._state.adding:
373 self.apply_type_defaults()
375 super().save(*args, **kwargs)
377 def apply_type_defaults(self):
378 """
379 Populate any empty fields with defaults from the assigned VirtualMachineType.
380 """
381 if not self.virtual_machine_type_id:
382 return
384 defaults = {
385 'platform_id': 'default_platform_id',
386 'vcpus': 'default_vcpus',
387 'memory': 'default_memory',
388 }
389 for field, default_field in defaults.items():
390 if getattr(self, field) is None:
391 default_value = getattr(self.virtual_machine_type, default_field)
392 if default_value is not None:
393 setattr(self, field, default_value)
395 def get_status_color(self):
396 return VirtualMachineStatusChoices.colors.get(self.status)
398 def get_start_on_boot_color(self):
399 return VirtualMachineStartOnBootChoices.colors.get(self.start_on_boot)
401 @property
402 def primary_ip(self):
403 if get_config().PREFER_IPV4 and self.primary_ip4:
404 return self.primary_ip4
405 if self.primary_ip6:
406 return self.primary_ip6
407 if self.primary_ip4:
408 return self.primary_ip4
409 return None
412#
413# VM components
414#
417class ComponentModel(OwnerMixin, NetBoxModel):
418 """
419 An abstract model inherited by any model which has a parent VirtualMachine.
420 """
421 virtual_machine = models.ForeignKey(
422 to='virtualization.VirtualMachine',
423 on_delete=models.CASCADE,
424 related_name='%(class)ss'
425 )
426 name = models.CharField(
427 verbose_name=_('name'),
428 max_length=64,
429 db_collation="natural_sort"
430 )
431 description = models.CharField(
432 verbose_name=_('description'),
433 max_length=200,
434 blank=True
435 )
437 class Meta:
438 abstract = True
439 constraints = (
440 models.UniqueConstraint(
441 fields=('virtual_machine', 'name'),
442 name='%(app_label)s_%(class)s_unique_virtual_machine_name'
443 ),
444 )
446 def __str__(self):
447 return self.name
449 def to_objectchange(self, action):
450 objectchange = super().to_objectchange(action)
451 objectchange.related_object = self.virtual_machine
452 return objectchange
454 @property
455 def parent_object(self):
456 return self.virtual_machine
459class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
460 name = models.CharField(
461 verbose_name=_('name'),
462 max_length=64,
463 )
464 _name = NaturalOrderingField(
465 target_field='name',
466 naturalize_function=naturalize_interface,
467 max_length=100,
468 blank=True
469 )
470 virtual_machine = models.ForeignKey(
471 to='virtualization.VirtualMachine',
472 on_delete=models.CASCADE,
473 related_name='interfaces' # Override ComponentModel
474 )
475 ip_addresses = GenericRelation(
476 to='ipam.IPAddress',
477 content_type_field='assigned_object_type',
478 object_id_field='assigned_object_id',
479 related_query_name='vminterface'
480 )
481 vrf = models.ForeignKey(
482 to='ipam.VRF',
483 on_delete=models.SET_NULL,
484 related_name='vminterfaces',
485 null=True,
486 blank=True,
487 verbose_name=_('VRF')
488 )
489 fhrp_group_assignments = GenericRelation(
490 to='ipam.FHRPGroupAssignment',
491 content_type_field='interface_type',
492 object_id_field='interface_id',
493 related_query_name='+'
494 )
495 tunnel_terminations = GenericRelation(
496 to='vpn.TunnelTermination',
497 content_type_field='termination_type',
498 object_id_field='termination_id',
499 related_query_name='vminterface',
500 )
501 l2vpn_terminations = GenericRelation(
502 to='vpn.L2VPNTermination',
503 content_type_field='assigned_object_type',
504 object_id_field='assigned_object_id',
505 related_query_name='vminterface',
506 )
507 mac_addresses = GenericRelation(
508 to='dcim.MACAddress',
509 content_type_field='assigned_object_type',
510 object_id_field='assigned_object_id',
511 related_query_name='vminterface'
512 )
514 class Meta(ComponentModel.Meta):
515 verbose_name = _('interface')
516 verbose_name_plural = _('interfaces')
517 ordering = ('virtual_machine', CollateAsChar('_name'))
519 def clean(self):
520 super().clean()
522 # Parent validation
524 # An interface cannot be its own parent
525 if self.pk and self.parent_id == self.pk:
526 raise ValidationError({'parent': _("An interface cannot be its own parent.")})
528 # An interface's parent must belong to the same virtual machine
529 if self.parent and self.parent.virtual_machine != self.virtual_machine:
530 raise ValidationError({
531 'parent': _(
532 "The selected parent interface ({parent}) belongs to a different virtual machine "
533 "({virtual_machine})."
534 ).format(parent=self.parent, virtual_machine=self.parent.virtual_machine)
535 })
537 # Bridge validation
539 # An interface cannot be bridged to itself
540 if self.pk and self.bridge_id == self.pk:
541 raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
543 # A bridged interface belong to the same virtual machine
544 if self.bridge and self.bridge.virtual_machine != self.virtual_machine:
545 raise ValidationError({
546 'bridge': _(
547 "The selected bridge interface ({bridge}) belongs to a different virtual machine "
548 "({virtual_machine})."
549 ).format(bridge=self.bridge, virtual_machine=self.bridge.virtual_machine)
550 })
552 # VLAN validation
554 # Validate untagged VLAN
555 if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
556 raise ValidationError({
557 'untagged_vlan': _(
558 "The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
559 "virtual machine, or it must be global."
560 ).format(untagged_vlan=self.untagged_vlan)
561 })
563 @property
564 def l2vpn_termination(self):
565 return self.l2vpn_terminations.first()
568class VirtualDisk(ComponentModel, TrackingModelMixin):
569 size = models.PositiveIntegerField(
570 verbose_name=_('size'),
571 )
573 class Meta(ComponentModel.Meta):
574 verbose_name = _('virtual disk')
575 verbose_name_plural = _('virtual disks')
576 ordering = ('virtual_machine', 'name')