models.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. from django.contrib.contenttypes.fields import GenericRelation
  2. from django.core.exceptions import ValidationError
  3. from django.core.validators import MinValueValidator
  4. from django.db import models
  5. from django.urls import reverse
  6. from dcim.models import BaseInterface, Device
  7. from extras.models import ConfigContextModel
  8. from extras.querysets import ConfigContextModelQuerySet
  9. from netbox.config import get_config
  10. from netbox.models import OrganizationalModel, NetBoxModel
  11. from utilities.fields import NaturalOrderingField
  12. from utilities.ordering import naturalize_interface
  13. from utilities.query_functions import CollateAsChar
  14. from .choices import *
  15. __all__ = (
  16. 'Cluster',
  17. 'ClusterGroup',
  18. 'ClusterType',
  19. 'VirtualMachine',
  20. 'VMInterface',
  21. )
  22. #
  23. # Cluster types
  24. #
  25. class ClusterType(OrganizationalModel):
  26. """
  27. A type of Cluster.
  28. """
  29. name = models.CharField(
  30. max_length=100,
  31. unique=True
  32. )
  33. slug = models.SlugField(
  34. max_length=100,
  35. unique=True
  36. )
  37. description = models.CharField(
  38. max_length=200,
  39. blank=True
  40. )
  41. class Meta:
  42. ordering = ['name']
  43. def __str__(self):
  44. return self.name
  45. def get_absolute_url(self):
  46. return reverse('virtualization:clustertype', args=[self.pk])
  47. #
  48. # Cluster groups
  49. #
  50. class ClusterGroup(OrganizationalModel):
  51. """
  52. An organizational group of Clusters.
  53. """
  54. name = models.CharField(
  55. max_length=100,
  56. unique=True
  57. )
  58. slug = models.SlugField(
  59. max_length=100,
  60. unique=True
  61. )
  62. description = models.CharField(
  63. max_length=200,
  64. blank=True
  65. )
  66. # Generic relations
  67. vlan_groups = GenericRelation(
  68. to='ipam.VLANGroup',
  69. content_type_field='scope_type',
  70. object_id_field='scope_id',
  71. related_query_name='cluster_group'
  72. )
  73. contacts = GenericRelation(
  74. to='tenancy.ContactAssignment'
  75. )
  76. class Meta:
  77. ordering = ['name']
  78. def __str__(self):
  79. return self.name
  80. def get_absolute_url(self):
  81. return reverse('virtualization:clustergroup', args=[self.pk])
  82. #
  83. # Clusters
  84. #
  85. class Cluster(NetBoxModel):
  86. """
  87. A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
  88. """
  89. name = models.CharField(
  90. max_length=100
  91. )
  92. type = models.ForeignKey(
  93. to=ClusterType,
  94. on_delete=models.PROTECT,
  95. related_name='clusters'
  96. )
  97. group = models.ForeignKey(
  98. to=ClusterGroup,
  99. on_delete=models.PROTECT,
  100. related_name='clusters',
  101. blank=True,
  102. null=True
  103. )
  104. status = models.CharField(
  105. max_length=50,
  106. choices=ClusterStatusChoices,
  107. default=ClusterStatusChoices.STATUS_ACTIVE
  108. )
  109. tenant = models.ForeignKey(
  110. to='tenancy.Tenant',
  111. on_delete=models.PROTECT,
  112. related_name='clusters',
  113. blank=True,
  114. null=True
  115. )
  116. site = models.ForeignKey(
  117. to='dcim.Site',
  118. on_delete=models.PROTECT,
  119. related_name='clusters',
  120. blank=True,
  121. null=True
  122. )
  123. comments = models.TextField(
  124. blank=True
  125. )
  126. # Generic relations
  127. vlan_groups = GenericRelation(
  128. to='ipam.VLANGroup',
  129. content_type_field='scope_type',
  130. object_id_field='scope_id',
  131. related_query_name='cluster'
  132. )
  133. contacts = GenericRelation(
  134. to='tenancy.ContactAssignment'
  135. )
  136. clone_fields = (
  137. 'type', 'group', 'status', 'tenant', 'site',
  138. )
  139. class Meta:
  140. ordering = ['name']
  141. unique_together = (
  142. ('group', 'name'),
  143. ('site', 'name'),
  144. )
  145. def __str__(self):
  146. return self.name
  147. @classmethod
  148. def get_prerequisite_models(cls):
  149. return [ClusterType, ]
  150. def get_absolute_url(self):
  151. return reverse('virtualization:cluster', args=[self.pk])
  152. def get_status_color(self):
  153. return ClusterStatusChoices.colors.get(self.status)
  154. def clean(self):
  155. super().clean()
  156. # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
  157. if self.pk and self.site:
  158. nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count()
  159. if nonsite_devices:
  160. raise ValidationError({
  161. 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format(
  162. nonsite_devices, self.site
  163. )
  164. })
  165. #
  166. # Virtual machines
  167. #
  168. class VirtualMachine(NetBoxModel, ConfigContextModel):
  169. """
  170. A virtual machine which runs inside a Cluster.
  171. """
  172. site = models.ForeignKey(
  173. to='dcim.Site',
  174. on_delete=models.PROTECT,
  175. related_name='virtual_machines',
  176. blank=True,
  177. null=True
  178. )
  179. cluster = models.ForeignKey(
  180. to='virtualization.Cluster',
  181. on_delete=models.PROTECT,
  182. related_name='virtual_machines',
  183. blank=True,
  184. null=True
  185. )
  186. device = models.ForeignKey(
  187. to='dcim.Device',
  188. on_delete=models.PROTECT,
  189. related_name='virtual_machines',
  190. blank=True,
  191. null=True
  192. )
  193. tenant = models.ForeignKey(
  194. to='tenancy.Tenant',
  195. on_delete=models.PROTECT,
  196. related_name='virtual_machines',
  197. blank=True,
  198. null=True
  199. )
  200. platform = models.ForeignKey(
  201. to='dcim.Platform',
  202. on_delete=models.SET_NULL,
  203. related_name='virtual_machines',
  204. blank=True,
  205. null=True
  206. )
  207. name = models.CharField(
  208. max_length=64
  209. )
  210. _name = NaturalOrderingField(
  211. target_field='name',
  212. max_length=100,
  213. blank=True
  214. )
  215. status = models.CharField(
  216. max_length=50,
  217. choices=VirtualMachineStatusChoices,
  218. default=VirtualMachineStatusChoices.STATUS_ACTIVE,
  219. verbose_name='Status'
  220. )
  221. role = models.ForeignKey(
  222. to='dcim.DeviceRole',
  223. on_delete=models.PROTECT,
  224. related_name='virtual_machines',
  225. limit_choices_to={'vm_role': True},
  226. blank=True,
  227. null=True
  228. )
  229. primary_ip4 = models.OneToOneField(
  230. to='ipam.IPAddress',
  231. on_delete=models.SET_NULL,
  232. related_name='+',
  233. blank=True,
  234. null=True,
  235. verbose_name='Primary IPv4'
  236. )
  237. primary_ip6 = models.OneToOneField(
  238. to='ipam.IPAddress',
  239. on_delete=models.SET_NULL,
  240. related_name='+',
  241. blank=True,
  242. null=True,
  243. verbose_name='Primary IPv6'
  244. )
  245. vcpus = models.DecimalField(
  246. max_digits=6,
  247. decimal_places=2,
  248. blank=True,
  249. null=True,
  250. verbose_name='vCPUs',
  251. validators=(
  252. MinValueValidator(0.01),
  253. )
  254. )
  255. memory = models.PositiveIntegerField(
  256. blank=True,
  257. null=True,
  258. verbose_name='Memory (MB)'
  259. )
  260. disk = models.PositiveIntegerField(
  261. blank=True,
  262. null=True,
  263. verbose_name='Disk (GB)'
  264. )
  265. comments = models.TextField(
  266. blank=True
  267. )
  268. # Generic relation
  269. contacts = GenericRelation(
  270. to='tenancy.ContactAssignment'
  271. )
  272. objects = ConfigContextModelQuerySet.as_manager()
  273. clone_fields = (
  274. 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
  275. )
  276. class Meta:
  277. ordering = ('_name', 'pk') # Name may be non-unique
  278. unique_together = [
  279. ['cluster', 'tenant', 'name']
  280. ]
  281. def __str__(self):
  282. return self.name
  283. @classmethod
  284. def get_prerequisite_models(cls):
  285. return [Cluster, ]
  286. def get_absolute_url(self):
  287. return reverse('virtualization:virtualmachine', args=[self.pk])
  288. def validate_unique(self, exclude=None):
  289. # Check for a duplicate name on a VM assigned to the same Cluster and no Tenant. This is necessary
  290. # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
  291. # of the uniqueness constraint without manual intervention.
  292. if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter(
  293. name=self.name, cluster=self.cluster, tenant__isnull=True
  294. ):
  295. raise ValidationError({
  296. 'name': 'A virtual machine with this name already exists in the assigned cluster.'
  297. })
  298. super().validate_unique(exclude)
  299. def clean(self):
  300. super().clean()
  301. # Must be assigned to a site and/or cluster
  302. if not self.site and not self.cluster:
  303. raise ValidationError({
  304. 'cluster': f'A virtual machine must be assigned to a site and/or cluster.'
  305. })
  306. # Validate site for cluster & device
  307. if self.cluster and self.cluster.site != self.site:
  308. raise ValidationError({
  309. 'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).'
  310. })
  311. if self.device and self.device.site != self.site:
  312. raise ValidationError({
  313. 'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).'
  314. })
  315. # Validate assigned cluster device
  316. if self.device and not self.cluster:
  317. raise ValidationError({
  318. 'device': f'Must specify a cluster when assigning a host device.'
  319. })
  320. if self.device and self.device not in self.cluster.devices.all():
  321. raise ValidationError({
  322. 'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).'
  323. })
  324. # Validate primary IP addresses
  325. interfaces = self.interfaces.all() if self.pk else None
  326. for family in (4, 6):
  327. field = f'primary_ip{family}'
  328. ip = getattr(self, field)
  329. if ip is not None:
  330. if ip.address.version != family:
  331. raise ValidationError({
  332. field: f"Must be an IPv{family} address. ({ip} is an IPv{ip.address.version} address.)",
  333. })
  334. if ip.assigned_object in interfaces:
  335. pass
  336. elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces:
  337. pass
  338. else:
  339. raise ValidationError({
  340. field: f"The specified IP address ({ip}) is not assigned to this VM.",
  341. })
  342. def get_status_color(self):
  343. return VirtualMachineStatusChoices.colors.get(self.status)
  344. @property
  345. def primary_ip(self):
  346. if get_config().PREFER_IPV4 and self.primary_ip4:
  347. return self.primary_ip4
  348. elif self.primary_ip6:
  349. return self.primary_ip6
  350. elif self.primary_ip4:
  351. return self.primary_ip4
  352. else:
  353. return None
  354. #
  355. # Interfaces
  356. #
  357. class VMInterface(NetBoxModel, BaseInterface):
  358. virtual_machine = models.ForeignKey(
  359. to='virtualization.VirtualMachine',
  360. on_delete=models.CASCADE,
  361. related_name='interfaces'
  362. )
  363. name = models.CharField(
  364. max_length=64
  365. )
  366. _name = NaturalOrderingField(
  367. target_field='name',
  368. naturalize_function=naturalize_interface,
  369. max_length=100,
  370. blank=True
  371. )
  372. description = models.CharField(
  373. max_length=200,
  374. blank=True
  375. )
  376. untagged_vlan = models.ForeignKey(
  377. to='ipam.VLAN',
  378. on_delete=models.SET_NULL,
  379. related_name='vminterfaces_as_untagged',
  380. null=True,
  381. blank=True,
  382. verbose_name='Untagged VLAN'
  383. )
  384. tagged_vlans = models.ManyToManyField(
  385. to='ipam.VLAN',
  386. related_name='vminterfaces_as_tagged',
  387. blank=True,
  388. verbose_name='Tagged VLANs'
  389. )
  390. ip_addresses = GenericRelation(
  391. to='ipam.IPAddress',
  392. content_type_field='assigned_object_type',
  393. object_id_field='assigned_object_id',
  394. related_query_name='vminterface'
  395. )
  396. vrf = models.ForeignKey(
  397. to='ipam.VRF',
  398. on_delete=models.SET_NULL,
  399. related_name='vminterfaces',
  400. null=True,
  401. blank=True,
  402. verbose_name='VRF'
  403. )
  404. fhrp_group_assignments = GenericRelation(
  405. to='ipam.FHRPGroupAssignment',
  406. content_type_field='interface_type',
  407. object_id_field='interface_id',
  408. related_query_name='+'
  409. )
  410. l2vpn_terminations = GenericRelation(
  411. to='ipam.L2VPNTermination',
  412. content_type_field='assigned_object_type',
  413. object_id_field='assigned_object_id',
  414. related_query_name='vminterface',
  415. )
  416. class Meta:
  417. verbose_name = 'interface'
  418. ordering = ('virtual_machine', CollateAsChar('_name'))
  419. unique_together = ('virtual_machine', 'name')
  420. def __str__(self):
  421. return self.name
  422. def get_absolute_url(self):
  423. return reverse('virtualization:vminterface', kwargs={'pk': self.pk})
  424. def clean(self):
  425. super().clean()
  426. # Parent validation
  427. # An interface cannot be its own parent
  428. if self.pk and self.parent_id == self.pk:
  429. raise ValidationError({'parent': "An interface cannot be its own parent."})
  430. # An interface's parent must belong to the same virtual machine
  431. if self.parent and self.parent.virtual_machine != self.virtual_machine:
  432. raise ValidationError({
  433. 'parent': f"The selected parent interface ({self.parent}) belongs to a different virtual machine "
  434. f"({self.parent.virtual_machine})."
  435. })
  436. # Bridge validation
  437. # An interface cannot be bridged to itself
  438. if self.pk and self.bridge_id == self.pk:
  439. raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
  440. # A bridged interface belong to the same virtual machine
  441. if self.bridge and self.bridge.virtual_machine != self.virtual_machine:
  442. raise ValidationError({
  443. 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different virtual machine "
  444. f"({self.bridge.virtual_machine})."
  445. })
  446. # VLAN validation
  447. # Validate untagged VLAN
  448. if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
  449. raise ValidationError({
  450. 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
  451. f"interface's parent virtual machine, or it must be global."
  452. })
  453. def to_objectchange(self, action):
  454. objectchange = super().to_objectchange(action)
  455. objectchange.related_object = self.virtual_machine
  456. return objectchange
  457. @property
  458. def parent_object(self):
  459. return self.virtual_machine
  460. @property
  461. def l2vpn_termination(self):
  462. return self.l2vpn_terminations.first()