models.py 13 KB

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