virtualmachines.py 14 KB

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