models.py 15 KB

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