configs.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. from django.conf import settings
  2. from django.core.validators import ValidationError
  3. from django.db import models
  4. from django.urls import reverse
  5. from django.utils.translation import gettext_lazy as _
  6. from jinja2.loaders import BaseLoader
  7. from jinja2.sandbox import SandboxedEnvironment
  8. from extras.querysets import ConfigContextQuerySet
  9. from netbox.config import get_config
  10. from netbox.models import ChangeLoggedModel
  11. from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
  12. from utilities.jinja2 import ConfigTemplateLoader
  13. from utilities.utils import deepmerge
  14. __all__ = (
  15. 'ConfigContext',
  16. 'ConfigContextModel',
  17. 'ConfigTemplate',
  18. )
  19. #
  20. # Config contexts
  21. #
  22. class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
  23. """
  24. A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
  25. qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
  26. will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
  27. """
  28. name = models.CharField(
  29. verbose_name=_('name'),
  30. max_length=100,
  31. unique=True
  32. )
  33. weight = models.PositiveSmallIntegerField(
  34. verbose_name=_('weight'),
  35. default=1000
  36. )
  37. description = models.CharField(
  38. verbose_name=_('description'),
  39. max_length=200,
  40. blank=True
  41. )
  42. is_active = models.BooleanField(
  43. verbose_name=_('is active'),
  44. default=True,
  45. )
  46. regions = models.ManyToManyField(
  47. to='dcim.Region',
  48. related_name='+',
  49. blank=True
  50. )
  51. site_groups = models.ManyToManyField(
  52. to='dcim.SiteGroup',
  53. related_name='+',
  54. blank=True
  55. )
  56. sites = models.ManyToManyField(
  57. to='dcim.Site',
  58. related_name='+',
  59. blank=True
  60. )
  61. locations = models.ManyToManyField(
  62. to='dcim.Location',
  63. related_name='+',
  64. blank=True
  65. )
  66. device_types = models.ManyToManyField(
  67. to='dcim.DeviceType',
  68. related_name='+',
  69. blank=True
  70. )
  71. roles = models.ManyToManyField(
  72. to='dcim.DeviceRole',
  73. related_name='+',
  74. blank=True
  75. )
  76. platforms = models.ManyToManyField(
  77. to='dcim.Platform',
  78. related_name='+',
  79. blank=True
  80. )
  81. cluster_types = models.ManyToManyField(
  82. to='virtualization.ClusterType',
  83. related_name='+',
  84. blank=True
  85. )
  86. cluster_groups = models.ManyToManyField(
  87. to='virtualization.ClusterGroup',
  88. related_name='+',
  89. blank=True
  90. )
  91. clusters = models.ManyToManyField(
  92. to='virtualization.Cluster',
  93. related_name='+',
  94. blank=True
  95. )
  96. tenant_groups = models.ManyToManyField(
  97. to='tenancy.TenantGroup',
  98. related_name='+',
  99. blank=True
  100. )
  101. tenants = models.ManyToManyField(
  102. to='tenancy.Tenant',
  103. related_name='+',
  104. blank=True
  105. )
  106. tags = models.ManyToManyField(
  107. to='extras.Tag',
  108. related_name='+',
  109. blank=True
  110. )
  111. data = models.JSONField()
  112. objects = ConfigContextQuerySet.as_manager()
  113. clone_fields = (
  114. 'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
  115. 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
  116. 'tenants', 'tags', 'data',
  117. )
  118. class Meta:
  119. ordering = ['weight', 'name']
  120. def __str__(self):
  121. return self.name
  122. def get_absolute_url(self):
  123. return reverse('extras:configcontext', kwargs={'pk': self.pk})
  124. @property
  125. def docs_url(self):
  126. return f'{settings.STATIC_URL}docs/models/extras/configcontext/'
  127. def clean(self):
  128. super().clean()
  129. # Verify that JSON data is provided as an object
  130. if type(self.data) is not dict:
  131. raise ValidationError(
  132. {'data': _('JSON data must be in object form. Example: {"foo": 123}')}
  133. )
  134. def sync_data(self):
  135. """
  136. Synchronize context data from the designated DataFile (if any).
  137. """
  138. self.data = self.data_file.get_data()
  139. sync_data.alters_data = True
  140. class ConfigContextModel(models.Model):
  141. """
  142. A model which includes local configuration context data. This local data will override any inherited data from
  143. ConfigContexts.
  144. """
  145. local_context_data = models.JSONField(
  146. blank=True,
  147. null=True,
  148. help_text=_(
  149. "Local config context data takes precedence over source contexts in the final rendered config context"
  150. )
  151. )
  152. class Meta:
  153. abstract = True
  154. def get_config_context(self):
  155. """
  156. Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
  157. Return the rendered configuration context for a device or VM.
  158. """
  159. data = {}
  160. if not hasattr(self, 'config_context_data'):
  161. # The annotation is not available, so we fall back to manually querying for the config context objects
  162. config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True)
  163. else:
  164. # The attribute may exist, but the annotated value could be None if there is no config context data
  165. config_context_data = self.config_context_data or []
  166. for context in config_context_data:
  167. data = deepmerge(data, context)
  168. # If the object has local config context data defined, merge it last
  169. if self.local_context_data:
  170. data = deepmerge(data, self.local_context_data)
  171. return data
  172. def clean(self):
  173. super().clean()
  174. # Verify that JSON data is provided as an object
  175. if self.local_context_data and type(self.local_context_data) is not dict:
  176. raise ValidationError(
  177. {'local_context_data': _('JSON data must be in object form. Example: {"foo": 123}')}
  178. )
  179. #
  180. # Config templates
  181. #
  182. class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
  183. name = models.CharField(
  184. verbose_name=_('name'),
  185. max_length=100
  186. )
  187. description = models.CharField(
  188. verbose_name=_('description'),
  189. max_length=200,
  190. blank=True
  191. )
  192. template_code = models.TextField(
  193. verbose_name=_('template code'),
  194. help_text=_('Jinja2 template code.')
  195. )
  196. environment_params = models.JSONField(
  197. verbose_name=_('environment parameters'),
  198. blank=True,
  199. null=True,
  200. default=dict,
  201. help_text=_(
  202. 'Any <a href="https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment">additional parameters</a>'
  203. ' to pass when constructing the Jinja2 environment.'
  204. )
  205. )
  206. class Meta:
  207. ordering = ('name',)
  208. def __str__(self):
  209. return self.name
  210. def get_absolute_url(self):
  211. return reverse('extras:configtemplate', args=[self.pk])
  212. def sync_data(self):
  213. """
  214. Synchronize template content from the designated DataFile (if any).
  215. """
  216. self.template_code = self.data_file.data_as_string
  217. sync_data.alters_data = True
  218. def render(self, context=None):
  219. """
  220. Render the contents of the template.
  221. """
  222. context = context or {}
  223. # Initialize the Jinja2 environment and instantiate the Template
  224. environment = self._get_environment()
  225. if self.data_file:
  226. template = environment.get_template(self.data_file.path)
  227. else:
  228. template = environment.from_string(self.template_code)
  229. output = template.render(**context)
  230. # Replace CRLF-style line terminators
  231. return output.replace('\r\n', '\n')
  232. def _get_environment(self):
  233. """
  234. Instantiate and return a Jinja2 environment suitable for rendering the ConfigTemplate.
  235. """
  236. # Initialize the template loader & cache the base template code (if applicable)
  237. if self.data_file:
  238. loader = ConfigTemplateLoader(data_source=self.data_source)
  239. loader.cache_templates({
  240. self.data_file.path: self.template_code
  241. })
  242. else:
  243. loader = BaseLoader()
  244. # Initialize the environment
  245. env_params = self.environment_params or {}
  246. environment = SandboxedEnvironment(loader=loader, **env_params)
  247. environment.filters.update(get_config().JINJA2_FILTERS)
  248. return environment