virtualmachines.py 19 KB

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