virtualmachines.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. import decimal
  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.db.models import Q, Sum
  7. from django.db.models.functions import Lower
  8. from django.urls import reverse
  9. from django.utils.translation import gettext_lazy as _
  10. from dcim.models import BaseInterface
  11. from dcim.models.mixins import RenderConfigMixin
  12. from extras.models import ConfigContextModel
  13. from extras.querysets import ConfigContextModelQuerySet
  14. from netbox.config import get_config
  15. from netbox.models import NetBoxModel, PrimaryModel
  16. from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
  17. from utilities.fields import CounterCacheField, NaturalOrderingField
  18. from utilities.ordering import naturalize_interface
  19. from utilities.query_functions import CollateAsChar
  20. from utilities.tracking import TrackingModelMixin
  21. from virtualization.choices import *
  22. __all__ = (
  23. 'VirtualDisk',
  24. 'VirtualMachine',
  25. 'VMInterface',
  26. )
  27. class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel):
  28. """
  29. A virtual machine which runs inside a Cluster.
  30. """
  31. site = models.ForeignKey(
  32. to='dcim.Site',
  33. on_delete=models.PROTECT,
  34. related_name='virtual_machines',
  35. blank=True,
  36. null=True
  37. )
  38. cluster = models.ForeignKey(
  39. to='virtualization.Cluster',
  40. on_delete=models.PROTECT,
  41. related_name='virtual_machines',
  42. blank=True,
  43. null=True
  44. )
  45. device = models.ForeignKey(
  46. to='dcim.Device',
  47. on_delete=models.PROTECT,
  48. related_name='virtual_machines',
  49. blank=True,
  50. null=True
  51. )
  52. tenant = models.ForeignKey(
  53. to='tenancy.Tenant',
  54. on_delete=models.PROTECT,
  55. related_name='virtual_machines',
  56. blank=True,
  57. null=True
  58. )
  59. platform = models.ForeignKey(
  60. to='dcim.Platform',
  61. on_delete=models.SET_NULL,
  62. related_name='virtual_machines',
  63. blank=True,
  64. null=True
  65. )
  66. name = models.CharField(
  67. verbose_name=_('name'),
  68. max_length=64
  69. )
  70. _name = NaturalOrderingField(
  71. target_field='name',
  72. max_length=100,
  73. blank=True
  74. )
  75. status = models.CharField(
  76. max_length=50,
  77. choices=VirtualMachineStatusChoices,
  78. default=VirtualMachineStatusChoices.STATUS_ACTIVE,
  79. verbose_name=_('status')
  80. )
  81. role = models.ForeignKey(
  82. to='dcim.DeviceRole',
  83. on_delete=models.PROTECT,
  84. related_name='virtual_machines',
  85. limit_choices_to={'vm_role': True},
  86. blank=True,
  87. null=True
  88. )
  89. primary_ip4 = models.OneToOneField(
  90. to='ipam.IPAddress',
  91. on_delete=models.SET_NULL,
  92. related_name='+',
  93. blank=True,
  94. null=True,
  95. verbose_name=_('primary IPv4')
  96. )
  97. primary_ip6 = models.OneToOneField(
  98. to='ipam.IPAddress',
  99. on_delete=models.SET_NULL,
  100. related_name='+',
  101. blank=True,
  102. null=True,
  103. verbose_name=_('primary IPv6')
  104. )
  105. vcpus = models.DecimalField(
  106. max_digits=6,
  107. decimal_places=2,
  108. blank=True,
  109. null=True,
  110. verbose_name=_('vCPUs'),
  111. validators=(
  112. MinValueValidator(decimal.Decimal(0.01)),
  113. )
  114. )
  115. memory = models.PositiveIntegerField(
  116. blank=True,
  117. null=True,
  118. verbose_name=_('memory (MB)')
  119. )
  120. disk = models.PositiveIntegerField(
  121. blank=True,
  122. null=True,
  123. verbose_name=_('disk (MB)')
  124. )
  125. serial = models.CharField(
  126. verbose_name=_('serial number'),
  127. blank=True,
  128. max_length=50
  129. )
  130. # Counter fields
  131. interface_count = CounterCacheField(
  132. to_model='virtualization.VMInterface',
  133. to_field='virtual_machine'
  134. )
  135. virtual_disk_count = CounterCacheField(
  136. to_model='virtualization.VirtualDisk',
  137. to_field='virtual_machine'
  138. )
  139. objects = ConfigContextModelQuerySet.as_manager()
  140. clone_fields = (
  141. 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
  142. )
  143. prerequisite_models = (
  144. 'virtualization.Cluster',
  145. )
  146. class Meta:
  147. ordering = ('_name', 'pk') # Name may be non-unique
  148. constraints = (
  149. models.UniqueConstraint(
  150. Lower('name'), 'cluster', 'tenant',
  151. name='%(app_label)s_%(class)s_unique_name_cluster_tenant'
  152. ),
  153. models.UniqueConstraint(
  154. Lower('name'), 'cluster',
  155. name='%(app_label)s_%(class)s_unique_name_cluster',
  156. condition=Q(tenant__isnull=True),
  157. violation_error_message=_("Virtual machine name must be unique per cluster.")
  158. ),
  159. )
  160. verbose_name = _('virtual machine')
  161. verbose_name_plural = _('virtual machines')
  162. def __str__(self):
  163. return self.name
  164. def get_absolute_url(self):
  165. return reverse('virtualization:virtualmachine', args=[self.pk])
  166. def clean(self):
  167. super().clean()
  168. # Must be assigned to a site and/or cluster
  169. if not self.site and not self.cluster:
  170. raise ValidationError({
  171. 'cluster': _('A virtual machine must be assigned to a site and/or cluster.')
  172. })
  173. # Validate site for cluster & VM
  174. if self.cluster and self.site and self.cluster.site and self.cluster.site != self.site:
  175. raise ValidationError({
  176. 'cluster': _(
  177. 'The selected cluster ({cluster}) is not assigned to this site ({site}).'
  178. ).format(cluster=self.cluster, site=self.site)
  179. })
  180. # Validate assigned cluster device
  181. if self.device and not self.cluster:
  182. raise ValidationError({
  183. 'device': _('Must specify a cluster when assigning a host device.')
  184. })
  185. if self.device and self.device not in self.cluster.devices.all():
  186. raise ValidationError({
  187. 'device': _(
  188. "The selected device ({device}) is not assigned to this cluster ({cluster})."
  189. ).format(device=self.device, cluster=self.cluster)
  190. })
  191. # Validate aggregate disk size
  192. if self.pk:
  193. total_disk = self.virtualdisks.aggregate(Sum('size', default=0))['size__sum']
  194. if total_disk and self.disk is None:
  195. self.disk = total_disk
  196. elif total_disk and self.disk != total_disk:
  197. raise ValidationError({
  198. 'disk': _(
  199. "The specified disk size ({size}) must match the aggregate size of assigned virtual disks "
  200. "({total_size})."
  201. ).format(size=self.disk, total_size=total_disk)
  202. })
  203. # Validate primary IP addresses
  204. interfaces = self.interfaces.all() if self.pk else None
  205. for family in (4, 6):
  206. field = f'primary_ip{family}'
  207. ip = getattr(self, field)
  208. if ip is not None:
  209. if ip.address.version != family:
  210. raise ValidationError({
  211. field: _(
  212. "Must be an IPv{family} address. ({ip} is an IPv{version} address.)"
  213. ).format(family=family, ip=ip, version=ip.address.version)
  214. })
  215. if ip.assigned_object in interfaces:
  216. pass
  217. elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces:
  218. pass
  219. else:
  220. raise ValidationError({
  221. field: _("The specified IP address ({ip}) is not assigned to this VM.").format(ip=ip),
  222. })
  223. def save(self, *args, **kwargs):
  224. # Assign site from cluster if not set
  225. if self.cluster and not self.site:
  226. self.site = self.cluster.site
  227. super().save(*args, **kwargs)
  228. def get_status_color(self):
  229. return VirtualMachineStatusChoices.colors.get(self.status)
  230. @property
  231. def primary_ip(self):
  232. if get_config().PREFER_IPV4 and self.primary_ip4:
  233. return self.primary_ip4
  234. elif self.primary_ip6:
  235. return self.primary_ip6
  236. elif self.primary_ip4:
  237. return self.primary_ip4
  238. else:
  239. return None
  240. #
  241. # VM components
  242. #
  243. class ComponentModel(NetBoxModel):
  244. """
  245. An abstract model inherited by any model which has a parent VirtualMachine.
  246. """
  247. virtual_machine = models.ForeignKey(
  248. to='virtualization.VirtualMachine',
  249. on_delete=models.CASCADE,
  250. related_name='%(class)ss'
  251. )
  252. name = models.CharField(
  253. verbose_name=_('name'),
  254. max_length=64
  255. )
  256. _name = NaturalOrderingField(
  257. target_field='name',
  258. naturalize_function=naturalize_interface,
  259. max_length=100,
  260. blank=True
  261. )
  262. description = models.CharField(
  263. verbose_name=_('description'),
  264. max_length=200,
  265. blank=True
  266. )
  267. class Meta:
  268. abstract = True
  269. ordering = ('virtual_machine', CollateAsChar('_name'))
  270. constraints = (
  271. models.UniqueConstraint(
  272. fields=('virtual_machine', 'name'),
  273. name='%(app_label)s_%(class)s_unique_virtual_machine_name'
  274. ),
  275. )
  276. def __str__(self):
  277. return self.name
  278. def to_objectchange(self, action):
  279. objectchange = super().to_objectchange(action)
  280. objectchange.related_object = self.virtual_machine
  281. return objectchange
  282. @property
  283. def parent_object(self):
  284. return self.virtual_machine
  285. class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
  286. virtual_machine = models.ForeignKey(
  287. to='virtualization.VirtualMachine',
  288. on_delete=models.CASCADE,
  289. related_name='interfaces' # Override ComponentModel
  290. )
  291. _name = NaturalOrderingField(
  292. target_field='name',
  293. naturalize_function=naturalize_interface,
  294. max_length=100,
  295. blank=True
  296. )
  297. untagged_vlan = models.ForeignKey(
  298. to='ipam.VLAN',
  299. on_delete=models.SET_NULL,
  300. related_name='vminterfaces_as_untagged',
  301. null=True,
  302. blank=True,
  303. verbose_name=_('untagged VLAN')
  304. )
  305. tagged_vlans = models.ManyToManyField(
  306. to='ipam.VLAN',
  307. related_name='vminterfaces_as_tagged',
  308. blank=True,
  309. verbose_name=_('tagged VLANs')
  310. )
  311. ip_addresses = GenericRelation(
  312. to='ipam.IPAddress',
  313. content_type_field='assigned_object_type',
  314. object_id_field='assigned_object_id',
  315. related_query_name='vminterface'
  316. )
  317. vrf = models.ForeignKey(
  318. to='ipam.VRF',
  319. on_delete=models.SET_NULL,
  320. related_name='vminterfaces',
  321. null=True,
  322. blank=True,
  323. verbose_name=_('VRF')
  324. )
  325. fhrp_group_assignments = GenericRelation(
  326. to='ipam.FHRPGroupAssignment',
  327. content_type_field='interface_type',
  328. object_id_field='interface_id',
  329. related_query_name='+'
  330. )
  331. tunnel_terminations = GenericRelation(
  332. to='vpn.TunnelTermination',
  333. content_type_field='termination_type',
  334. object_id_field='termination_id',
  335. related_query_name='vminterface',
  336. )
  337. l2vpn_terminations = GenericRelation(
  338. to='vpn.L2VPNTermination',
  339. content_type_field='assigned_object_type',
  340. object_id_field='assigned_object_id',
  341. related_query_name='vminterface',
  342. )
  343. class Meta(ComponentModel.Meta):
  344. verbose_name = _('interface')
  345. verbose_name_plural = _('interfaces')
  346. def get_absolute_url(self):
  347. return reverse('virtualization:vminterface', kwargs={'pk': self.pk})
  348. def clean(self):
  349. super().clean()
  350. # Parent validation
  351. # An interface cannot be its own parent
  352. if self.pk and self.parent_id == self.pk:
  353. raise ValidationError({'parent': _("An interface cannot be its own parent.")})
  354. # An interface's parent must belong to the same virtual machine
  355. if self.parent and self.parent.virtual_machine != self.virtual_machine:
  356. raise ValidationError({
  357. 'parent': _(
  358. "The selected parent interface ({parent}) belongs to a different virtual machine "
  359. "({virtual_machine})."
  360. ).format(parent=self.parent, virtual_machine=self.parent.virtual_machine)
  361. })
  362. # Bridge validation
  363. # An interface cannot be bridged to itself
  364. if self.pk and self.bridge_id == self.pk:
  365. raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
  366. # A bridged interface belong to the same virtual machine
  367. if self.bridge and self.bridge.virtual_machine != self.virtual_machine:
  368. raise ValidationError({
  369. 'bridge': _(
  370. "The selected bridge interface ({bridge}) belongs to a different virtual machine "
  371. "({virtual_machine})."
  372. ).format(bridge=self.bridge, virtual_machine=self.bridge.virtual_machine)
  373. })
  374. # VLAN validation
  375. # Validate untagged VLAN
  376. if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
  377. raise ValidationError({
  378. 'untagged_vlan': _(
  379. "The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
  380. "virtual machine, or it must be global."
  381. ).format(untagged_vlan=self.untagged_vlan)
  382. })
  383. @property
  384. def l2vpn_termination(self):
  385. return self.l2vpn_terminations.first()
  386. class VirtualDisk(ComponentModel, TrackingModelMixin):
  387. size = models.PositiveIntegerField(
  388. verbose_name=_('size (GB)'),
  389. )
  390. class Meta(ComponentModel.Meta):
  391. verbose_name = _('virtual disk')
  392. verbose_name_plural = _('virtual disks')
  393. def get_absolute_url(self):
  394. return reverse('virtualization:virtualdisk', args=[self.pk])