configs.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import jsonschema
  2. from collections import defaultdict
  3. from jsonschema.exceptions import ValidationError as JSONValidationError
  4. from django.conf import settings
  5. from django.core.validators import ValidationError
  6. from django.db import models
  7. from django.urls import reverse
  8. from django.utils.translation import gettext_lazy as _
  9. from core.models import ObjectType
  10. from extras.models.mixins import RenderTemplateMixin
  11. from extras.querysets import ConfigContextQuerySet
  12. from netbox.models import ChangeLoggedModel, PrimaryModel
  13. from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
  14. from utilities.data import deepmerge
  15. from utilities.jsonschema import validate_schema
  16. __all__ = (
  17. 'ConfigContext',
  18. 'ConfigContextModel',
  19. 'ConfigContextProfile',
  20. 'ConfigTemplate',
  21. )
  22. #
  23. # Config contexts
  24. #
  25. class ConfigContextProfile(SyncedDataMixin, PrimaryModel):
  26. """
  27. A profile which can be used to enforce parameters on a ConfigContext.
  28. """
  29. name = models.CharField(
  30. verbose_name=_('name'),
  31. max_length=100,
  32. unique=True
  33. )
  34. description = models.CharField(
  35. verbose_name=_('description'),
  36. max_length=200,
  37. blank=True
  38. )
  39. schema = models.JSONField(
  40. blank=True,
  41. null=True,
  42. validators=[validate_schema],
  43. verbose_name=_('schema'),
  44. help_text=_('A JSON schema specifying the structure of the context data for this profile')
  45. )
  46. clone_fields = ('schema',)
  47. class Meta:
  48. ordering = ('name',)
  49. verbose_name = _('config context profile')
  50. verbose_name_plural = _('config context profiles')
  51. def __str__(self):
  52. return self.name
  53. def sync_data(self):
  54. """
  55. Synchronize schema from the designated DataFile (if any).
  56. """
  57. self.schema = self.data_file.get_data()
  58. sync_data.alters_data = True
  59. class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
  60. """
  61. A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
  62. qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
  63. will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
  64. """
  65. name = models.CharField(
  66. verbose_name=_('name'),
  67. max_length=100,
  68. unique=True
  69. )
  70. profile = models.ForeignKey(
  71. to='extras.ConfigContextProfile',
  72. on_delete=models.PROTECT,
  73. blank=True,
  74. null=True,
  75. related_name='config_contexts',
  76. )
  77. weight = models.PositiveSmallIntegerField(
  78. verbose_name=_('weight'),
  79. default=1000
  80. )
  81. description = models.CharField(
  82. verbose_name=_('description'),
  83. max_length=200,
  84. blank=True
  85. )
  86. is_active = models.BooleanField(
  87. verbose_name=_('is active'),
  88. default=True,
  89. )
  90. regions = models.ManyToManyField(
  91. to='dcim.Region',
  92. related_name='+',
  93. blank=True
  94. )
  95. site_groups = models.ManyToManyField(
  96. to='dcim.SiteGroup',
  97. related_name='+',
  98. blank=True
  99. )
  100. sites = models.ManyToManyField(
  101. to='dcim.Site',
  102. related_name='+',
  103. blank=True
  104. )
  105. locations = models.ManyToManyField(
  106. to='dcim.Location',
  107. related_name='+',
  108. blank=True
  109. )
  110. device_types = models.ManyToManyField(
  111. to='dcim.DeviceType',
  112. related_name='+',
  113. blank=True
  114. )
  115. roles = models.ManyToManyField(
  116. to='dcim.DeviceRole',
  117. related_name='+',
  118. blank=True
  119. )
  120. platforms = models.ManyToManyField(
  121. to='dcim.Platform',
  122. related_name='+',
  123. blank=True
  124. )
  125. cluster_types = models.ManyToManyField(
  126. to='virtualization.ClusterType',
  127. related_name='+',
  128. blank=True
  129. )
  130. cluster_groups = models.ManyToManyField(
  131. to='virtualization.ClusterGroup',
  132. related_name='+',
  133. blank=True
  134. )
  135. clusters = models.ManyToManyField(
  136. to='virtualization.Cluster',
  137. related_name='+',
  138. blank=True
  139. )
  140. tenant_groups = models.ManyToManyField(
  141. to='tenancy.TenantGroup',
  142. related_name='+',
  143. blank=True
  144. )
  145. tenants = models.ManyToManyField(
  146. to='tenancy.Tenant',
  147. related_name='+',
  148. blank=True
  149. )
  150. tags = models.ManyToManyField(
  151. to='extras.Tag',
  152. related_name='+',
  153. blank=True
  154. )
  155. data = models.JSONField()
  156. objects = ConfigContextQuerySet.as_manager()
  157. clone_fields = (
  158. 'weight', 'profile', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles',
  159. 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
  160. )
  161. class Meta:
  162. ordering = ['weight', 'name']
  163. verbose_name = _('config context')
  164. verbose_name_plural = _('config contexts')
  165. def __str__(self):
  166. return self.name
  167. def get_absolute_url(self):
  168. return reverse('extras:configcontext', kwargs={'pk': self.pk})
  169. @property
  170. def docs_url(self):
  171. return f'{settings.STATIC_URL}docs/models/extras/configcontext/'
  172. def clean(self):
  173. super().clean()
  174. # Verify that JSON data is provided as an object
  175. if type(self.data) is not dict:
  176. raise ValidationError(
  177. {'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
  178. )
  179. # Validate config data against the assigned profile's schema (if any)
  180. if self.profile and self.profile.schema:
  181. try:
  182. jsonschema.validate(self.data, schema=self.profile.schema)
  183. except JSONValidationError as e:
  184. raise ValidationError(_("Data does not conform to profile schema: {error}").format(error=e))
  185. def sync_data(self):
  186. """
  187. Synchronize context data from the designated DataFile (if any).
  188. """
  189. self.data = self.data_file.get_data()
  190. sync_data.alters_data = True
  191. class ConfigContextModel(models.Model):
  192. """
  193. A model which includes local configuration context data. This local data will override any inherited data from
  194. ConfigContexts.
  195. """
  196. local_context_data = models.JSONField(
  197. blank=True,
  198. null=True,
  199. help_text=_(
  200. "Local config context data takes precedence over source contexts in the final rendered config context"
  201. )
  202. )
  203. class Meta:
  204. abstract = True
  205. def get_config_context(self):
  206. """
  207. Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
  208. Return the rendered configuration context for a device or VM.
  209. """
  210. data = {}
  211. if not hasattr(self, 'config_context_data'):
  212. # The annotation is not available, so we fall back to manually querying for the config context objects
  213. config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True) or []
  214. else:
  215. # The attribute may exist, but the annotated value could be None if there is no config context data
  216. config_context_data = self.config_context_data or []
  217. for context in config_context_data:
  218. data = deepmerge(data, context)
  219. # If the object has local config context data defined, merge it last
  220. if self.local_context_data:
  221. data = deepmerge(data, self.local_context_data)
  222. return data
  223. def clean(self):
  224. super().clean()
  225. # Verify that JSON data is provided as an object
  226. if self.local_context_data is not None and type(self.local_context_data) is not dict:
  227. raise ValidationError(
  228. {'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
  229. )
  230. #
  231. # Config templates
  232. #
  233. class ConfigTemplate(
  234. RenderTemplateMixin, SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel
  235. ):
  236. name = models.CharField(
  237. verbose_name=_('name'),
  238. max_length=100
  239. )
  240. description = models.CharField(
  241. verbose_name=_('description'),
  242. max_length=200,
  243. blank=True
  244. )
  245. class Meta:
  246. ordering = ('name',)
  247. verbose_name = _('config template')
  248. verbose_name_plural = _('config templates')
  249. def __str__(self):
  250. return self.name
  251. def get_absolute_url(self):
  252. return reverse('extras:configtemplate', args=[self.pk])
  253. def sync_data(self):
  254. """
  255. Synchronize template content from the designated DataFile (if any).
  256. """
  257. self.template_code = self.data_file.data_as_string
  258. sync_data.alters_data = True
  259. def get_context(self, context=None, queryset=None):
  260. _context = defaultdict(dict)
  261. # Populate all public models for reference within the template
  262. for object_type in ObjectType.objects.public():
  263. if model := object_type.model_class():
  264. _context[object_type.app_label][model.__name__] = model
  265. # Apply the provided context data, if any
  266. if context is not None:
  267. _context.update(context)
  268. return _context