models.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. from django.conf import settings
  2. from django.contrib.contenttypes.fields import GenericRelation
  3. from django.core.exceptions import ValidationError
  4. from django.db import models
  5. from django.urls import reverse
  6. from taggit.managers import TaggableManager
  7. from dcim.choices import InterfaceModeChoices
  8. from dcim.models import BaseInterface, Device
  9. from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
  10. from extras.utils import extras_features
  11. from utilities.models import ChangeLoggedModel
  12. from utilities.query_functions import CollateAsChar
  13. from utilities.querysets import RestrictedQuerySet
  14. from utilities.utils import serialize_object
  15. from .choices import *
  16. __all__ = (
  17. 'Cluster',
  18. 'ClusterGroup',
  19. 'ClusterType',
  20. 'VirtualMachine',
  21. 'VMInterface',
  22. )
  23. #
  24. # Cluster types
  25. #
  26. class ClusterType(ChangeLoggedModel):
  27. """
  28. A type of Cluster.
  29. """
  30. name = models.CharField(
  31. max_length=50,
  32. unique=True
  33. )
  34. slug = models.SlugField(
  35. unique=True
  36. )
  37. description = models.CharField(
  38. max_length=200,
  39. blank=True
  40. )
  41. objects = RestrictedQuerySet.as_manager()
  42. csv_headers = ['name', 'slug', 'description']
  43. class Meta:
  44. ordering = ['name']
  45. def __str__(self):
  46. return self.name
  47. def get_absolute_url(self):
  48. return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug)
  49. def to_csv(self):
  50. return (
  51. self.name,
  52. self.slug,
  53. self.description,
  54. )
  55. #
  56. # Cluster groups
  57. #
  58. class ClusterGroup(ChangeLoggedModel):
  59. """
  60. An organizational group of Clusters.
  61. """
  62. name = models.CharField(
  63. max_length=50,
  64. unique=True
  65. )
  66. slug = models.SlugField(
  67. unique=True
  68. )
  69. description = models.CharField(
  70. max_length=200,
  71. blank=True
  72. )
  73. objects = RestrictedQuerySet.as_manager()
  74. csv_headers = ['name', 'slug', 'description']
  75. class Meta:
  76. ordering = ['name']
  77. def __str__(self):
  78. return self.name
  79. def get_absolute_url(self):
  80. return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug)
  81. def to_csv(self):
  82. return (
  83. self.name,
  84. self.slug,
  85. self.description,
  86. )
  87. #
  88. # Clusters
  89. #
  90. @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
  91. class Cluster(ChangeLoggedModel, CustomFieldModel):
  92. """
  93. A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
  94. """
  95. name = models.CharField(
  96. max_length=100,
  97. unique=True
  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. custom_field_values = GenericRelation(
  129. to='extras.CustomFieldValue',
  130. content_type_field='obj_type',
  131. object_id_field='obj_id'
  132. )
  133. tags = TaggableManager(through=TaggedItem)
  134. objects = RestrictedQuerySet.as_manager()
  135. csv_headers = ['name', 'type', 'group', 'site', 'comments']
  136. clone_fields = [
  137. 'type', 'group', 'tenant', 'site',
  138. ]
  139. class Meta:
  140. ordering = ['name']
  141. def __str__(self):
  142. return self.name
  143. def get_absolute_url(self):
  144. return reverse('virtualization:cluster', args=[self.pk])
  145. def clean(self):
  146. # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
  147. if self.pk and self.site:
  148. nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count()
  149. if nonsite_devices:
  150. raise ValidationError({
  151. 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format(
  152. nonsite_devices, self.site
  153. )
  154. })
  155. def to_csv(self):
  156. return (
  157. self.name,
  158. self.type.name,
  159. self.group.name if self.group else None,
  160. self.site.name if self.site else None,
  161. self.tenant.name if self.tenant else None,
  162. self.comments,
  163. )
  164. #
  165. # Virtual machines
  166. #
  167. @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
  168. class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
  169. """
  170. A virtual machine which runs inside a Cluster.
  171. """
  172. cluster = models.ForeignKey(
  173. to='virtualization.Cluster',
  174. on_delete=models.PROTECT,
  175. related_name='virtual_machines'
  176. )
  177. tenant = models.ForeignKey(
  178. to='tenancy.Tenant',
  179. on_delete=models.PROTECT,
  180. related_name='virtual_machines',
  181. blank=True,
  182. null=True
  183. )
  184. platform = models.ForeignKey(
  185. to='dcim.Platform',
  186. on_delete=models.SET_NULL,
  187. related_name='virtual_machines',
  188. blank=True,
  189. null=True
  190. )
  191. name = models.CharField(
  192. max_length=64
  193. )
  194. status = models.CharField(
  195. max_length=50,
  196. choices=VirtualMachineStatusChoices,
  197. default=VirtualMachineStatusChoices.STATUS_ACTIVE,
  198. verbose_name='Status'
  199. )
  200. role = models.ForeignKey(
  201. to='dcim.DeviceRole',
  202. on_delete=models.PROTECT,
  203. related_name='virtual_machines',
  204. limit_choices_to={'vm_role': True},
  205. blank=True,
  206. null=True
  207. )
  208. primary_ip4 = models.OneToOneField(
  209. to='ipam.IPAddress',
  210. on_delete=models.SET_NULL,
  211. related_name='+',
  212. blank=True,
  213. null=True,
  214. verbose_name='Primary IPv4'
  215. )
  216. primary_ip6 = models.OneToOneField(
  217. to='ipam.IPAddress',
  218. on_delete=models.SET_NULL,
  219. related_name='+',
  220. blank=True,
  221. null=True,
  222. verbose_name='Primary IPv6'
  223. )
  224. vcpus = models.PositiveSmallIntegerField(
  225. blank=True,
  226. null=True,
  227. verbose_name='vCPUs'
  228. )
  229. memory = models.PositiveIntegerField(
  230. blank=True,
  231. null=True,
  232. verbose_name='Memory (MB)'
  233. )
  234. disk = models.PositiveIntegerField(
  235. blank=True,
  236. null=True,
  237. verbose_name='Disk (GB)'
  238. )
  239. comments = models.TextField(
  240. blank=True
  241. )
  242. custom_field_values = GenericRelation(
  243. to='extras.CustomFieldValue',
  244. content_type_field='obj_type',
  245. object_id_field='obj_id'
  246. )
  247. tags = TaggableManager(through=TaggedItem)
  248. objects = RestrictedQuerySet.as_manager()
  249. csv_headers = [
  250. 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
  251. ]
  252. clone_fields = [
  253. 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
  254. ]
  255. STATUS_CLASS_MAP = {
  256. VirtualMachineStatusChoices.STATUS_OFFLINE: 'warning',
  257. VirtualMachineStatusChoices.STATUS_ACTIVE: 'success',
  258. VirtualMachineStatusChoices.STATUS_PLANNED: 'info',
  259. VirtualMachineStatusChoices.STATUS_STAGED: 'primary',
  260. VirtualMachineStatusChoices.STATUS_FAILED: 'danger',
  261. VirtualMachineStatusChoices.STATUS_DECOMMISSIONING: 'warning',
  262. }
  263. class Meta:
  264. ordering = ('name', 'pk') # Name may be non-unique
  265. unique_together = [
  266. ['cluster', 'tenant', 'name']
  267. ]
  268. def __str__(self):
  269. return self.name
  270. def get_absolute_url(self):
  271. return reverse('virtualization:virtualmachine', args=[self.pk])
  272. def validate_unique(self, exclude=None):
  273. # Check for a duplicate name on a VM assigned to the same Cluster and no Tenant. This is necessary
  274. # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
  275. # of the uniqueness constraint without manual intervention.
  276. if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter(
  277. name=self.name, tenant__isnull=True
  278. ):
  279. raise ValidationError({
  280. 'name': 'A virtual machine with this name already exists.'
  281. })
  282. super().validate_unique(exclude)
  283. def clean(self):
  284. super().clean()
  285. # Validate primary IP addresses
  286. interfaces = self.interfaces.all()
  287. for field in ['primary_ip4', 'primary_ip6']:
  288. ip = getattr(self, field)
  289. if ip is not None:
  290. if ip.interface in interfaces:
  291. pass
  292. elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in interfaces:
  293. pass
  294. else:
  295. raise ValidationError({
  296. field: "The specified IP address ({}) is not assigned to this VM.".format(ip),
  297. })
  298. def to_csv(self):
  299. return (
  300. self.name,
  301. self.get_status_display(),
  302. self.role.name if self.role else None,
  303. self.cluster.name,
  304. self.tenant.name if self.tenant else None,
  305. self.platform.name if self.platform else None,
  306. self.vcpus,
  307. self.memory,
  308. self.disk,
  309. self.comments,
  310. )
  311. def get_status_class(self):
  312. return self.STATUS_CLASS_MAP.get(self.status)
  313. @property
  314. def primary_ip(self):
  315. if settings.PREFER_IPV4 and self.primary_ip4:
  316. return self.primary_ip4
  317. elif self.primary_ip6:
  318. return self.primary_ip6
  319. elif self.primary_ip4:
  320. return self.primary_ip4
  321. else:
  322. return None
  323. @property
  324. def site(self):
  325. return self.cluster.site
  326. #
  327. # Interfaces
  328. #
  329. @extras_features('graphs', 'export_templates', 'webhooks')
  330. class VMInterface(BaseInterface):
  331. virtual_machine = models.ForeignKey(
  332. to='virtualization.VirtualMachine',
  333. on_delete=models.CASCADE,
  334. related_name='interfaces'
  335. )
  336. description = models.CharField(
  337. max_length=200,
  338. blank=True
  339. )
  340. untagged_vlan = models.ForeignKey(
  341. to='ipam.VLAN',
  342. on_delete=models.SET_NULL,
  343. related_name='vm_interfaces_as_untagged',
  344. null=True,
  345. blank=True,
  346. verbose_name='Untagged VLAN'
  347. )
  348. tagged_vlans = models.ManyToManyField(
  349. to='ipam.VLAN',
  350. related_name='vm_interfaces_as_tagged',
  351. blank=True,
  352. verbose_name='Tagged VLANs'
  353. )
  354. ip_addresses = GenericRelation(
  355. to='ipam.IPAddress',
  356. content_type_field='assigned_object_type',
  357. object_id_field='assigned_object_id',
  358. related_query_name='vm_interface'
  359. )
  360. tags = TaggableManager(
  361. through=TaggedItem,
  362. related_name='vm_interface'
  363. )
  364. objects = RestrictedQuerySet.as_manager()
  365. csv_headers = [
  366. 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
  367. ]
  368. class Meta:
  369. verbose_name = 'interface'
  370. ordering = ('virtual_machine', CollateAsChar('_name'))
  371. unique_together = ('virtual_machine', 'name')
  372. def __str__(self):
  373. return self.name
  374. def get_absolute_url(self):
  375. return reverse('virtualization:interface', kwargs={'pk': self.pk})
  376. def to_csv(self):
  377. return (
  378. self.virtual_machine.name,
  379. self.name,
  380. self.enabled,
  381. self.mac_address,
  382. self.mtu,
  383. self.description,
  384. self.get_mode_display(),
  385. )
  386. def clean(self):
  387. # Validate untagged VLAN
  388. if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
  389. raise ValidationError({
  390. 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
  391. "virtual machine, or it must be global".format(self.untagged_vlan)
  392. })
  393. def save(self, *args, **kwargs):
  394. # Remove untagged VLAN assignment for non-802.1Q interfaces
  395. if self.mode is None:
  396. self.untagged_vlan = None
  397. # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
  398. if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
  399. self.tagged_vlans.clear()
  400. return super().save(*args, **kwargs)
  401. def to_objectchange(self, action):
  402. # Annotate the parent VirtualMachine
  403. return ObjectChange(
  404. changed_object=self,
  405. object_repr=str(self),
  406. action=action,
  407. related_object=self.virtual_machine,
  408. object_data=serialize_object(self)
  409. )
  410. @property
  411. def parent(self):
  412. return self.virtual_machine
  413. @property
  414. def count_ipaddresses(self):
  415. return self.ip_addresses.count()