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