models.py 13 KB

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