configs.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. import copy
  2. import os
  3. import re
  4. import sys
  5. import traceback
  6. import jsonschema
  7. from django.conf import settings
  8. from django.core.validators import ValidationError
  9. from django.db import models
  10. from django.db.models import Q
  11. from django.urls import reverse
  12. from django.utils.translation import gettext_lazy as _
  13. from jinja2.exceptions import TemplateError
  14. from jsonschema.exceptions import ValidationError as JSONValidationError
  15. from extras.models.mixins import RenderTemplateMixin
  16. from extras.querysets import ConfigContextQuerySet
  17. from netbox.models import ChangeLoggedModel, PrimaryModel
  18. from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
  19. from netbox.models.mixins import OwnerMixin
  20. from utilities.data import deepmerge
  21. from utilities.jsonschema import validate_schema
  22. __all__ = (
  23. 'ConfigContext',
  24. 'ConfigContextModel',
  25. 'ConfigContextProfile',
  26. 'ConfigTemplate',
  27. )
  28. #
  29. # Config contexts
  30. #
  31. class ConfigContextProfile(SyncedDataMixin, PrimaryModel):
  32. """
  33. A profile which can be used to enforce parameters on a ConfigContext.
  34. """
  35. name = models.CharField(
  36. verbose_name=_('name'),
  37. max_length=100,
  38. unique=True
  39. )
  40. description = models.CharField(
  41. verbose_name=_('description'),
  42. max_length=200,
  43. blank=True
  44. )
  45. schema = models.JSONField(
  46. blank=True,
  47. null=True,
  48. validators=[validate_schema],
  49. verbose_name=_('schema'),
  50. help_text=_('A JSON schema specifying the structure of the context data for this profile')
  51. )
  52. clone_fields = ('schema',)
  53. class Meta:
  54. ordering = ('name',)
  55. verbose_name = _('config context profile')
  56. verbose_name_plural = _('config context profiles')
  57. def __str__(self):
  58. return self.name
  59. def sync_data(self):
  60. """
  61. Synchronize schema from the designated DataFile (if any).
  62. """
  63. self.schema = self.validate_synced_value('schema', self.data_file.get_data())
  64. sync_data.alters_data = True
  65. class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, OwnerMixin, ChangeLoggedModel):
  66. """
  67. A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
  68. qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
  69. will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
  70. """
  71. name = models.CharField(
  72. verbose_name=_('name'),
  73. max_length=100,
  74. unique=True
  75. )
  76. profile = models.ForeignKey(
  77. to='extras.ConfigContextProfile',
  78. on_delete=models.PROTECT,
  79. blank=True,
  80. null=True,
  81. related_name='config_contexts',
  82. )
  83. weight = models.PositiveSmallIntegerField(
  84. verbose_name=_('weight'),
  85. default=1000
  86. )
  87. description = models.CharField(
  88. verbose_name=_('description'),
  89. max_length=200,
  90. blank=True
  91. )
  92. is_active = models.BooleanField(
  93. verbose_name=_('is active'),
  94. default=True,
  95. )
  96. regions = models.ManyToManyField(
  97. to='dcim.Region',
  98. related_name='+',
  99. blank=True
  100. )
  101. site_groups = models.ManyToManyField(
  102. to='dcim.SiteGroup',
  103. related_name='+',
  104. blank=True
  105. )
  106. sites = models.ManyToManyField(
  107. to='dcim.Site',
  108. related_name='+',
  109. blank=True
  110. )
  111. locations = models.ManyToManyField(
  112. to='dcim.Location',
  113. related_name='+',
  114. blank=True
  115. )
  116. device_types = models.ManyToManyField(
  117. to='dcim.DeviceType',
  118. related_name='+',
  119. blank=True
  120. )
  121. roles = models.ManyToManyField(
  122. to='dcim.DeviceRole',
  123. related_name='+',
  124. blank=True
  125. )
  126. platforms = models.ManyToManyField(
  127. to='dcim.Platform',
  128. related_name='+',
  129. blank=True
  130. )
  131. cluster_types = models.ManyToManyField(
  132. to='virtualization.ClusterType',
  133. related_name='+',
  134. blank=True
  135. )
  136. cluster_groups = models.ManyToManyField(
  137. to='virtualization.ClusterGroup',
  138. related_name='+',
  139. blank=True
  140. )
  141. clusters = models.ManyToManyField(
  142. to='virtualization.Cluster',
  143. related_name='+',
  144. blank=True
  145. )
  146. tenant_groups = models.ManyToManyField(
  147. to='tenancy.TenantGroup',
  148. related_name='+',
  149. blank=True
  150. )
  151. tenants = models.ManyToManyField(
  152. to='tenancy.Tenant',
  153. related_name='+',
  154. blank=True
  155. )
  156. tags = models.ManyToManyField(
  157. to='extras.Tag',
  158. related_name='+',
  159. blank=True
  160. )
  161. data = models.JSONField()
  162. objects = ConfigContextQuerySet.as_manager()
  163. clone_fields = (
  164. 'weight', 'profile', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles',
  165. 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
  166. )
  167. class Meta:
  168. ordering = ['weight', 'name']
  169. indexes = (
  170. models.Index(fields=('weight', 'name')), # Default ordering
  171. )
  172. verbose_name = _('config context')
  173. verbose_name_plural = _('config contexts')
  174. def __str__(self):
  175. return self.name
  176. def get_absolute_url(self):
  177. return reverse('extras:configcontext', kwargs={'pk': self.pk})
  178. @property
  179. def docs_url(self):
  180. return f'{settings.STATIC_URL}docs/models/extras/configcontext/'
  181. def clean(self):
  182. super().clean()
  183. # Verify that JSON data is provided as an object
  184. if type(self.data) is not dict:
  185. raise ValidationError(
  186. {'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
  187. )
  188. # Validate config data against the assigned profile's schema (if any)
  189. if self.profile and self.profile.schema:
  190. try:
  191. jsonschema.validate(self.data, schema=self.profile.schema)
  192. except JSONValidationError as e:
  193. raise ValidationError(_("Data does not conform to profile schema: {error}").format(error=e))
  194. def sync_data(self):
  195. """
  196. Synchronize context data from the designated DataFile (if any).
  197. """
  198. self.data = self.validate_synced_value('data', self.data_file.get_data())
  199. sync_data.alters_data = True
  200. def get_affected_objects(self, using=None):
  201. """
  202. Return a (device_qs, vm_qs) tuple of all Devices and VirtualMachines that fall within this
  203. ConfigContext's scope. This is the inverse of ConfigContextQuerySet.get_for_object().
  204. Used to determine which pre-rendered context caches must be invalidated when this
  205. ConfigContext changes.
  206. `using` pins every query (both the scope lookups and the returned querysets) to the given
  207. database alias; None defers to the router, as an unpinned query would.
  208. """
  209. from dcim.models import Device
  210. from virtualization.models import VirtualMachine
  211. device_q, vm_q = self._get_affected_object_filters(using=using)
  212. return (
  213. Device.objects.using(using).filter(device_q),
  214. VirtualMachine.objects.using(using).filter(vm_q),
  215. )
  216. def _get_affected_object_filters(self, using=None):
  217. """
  218. Build the Q expressions matching Devices and VirtualMachines in this context's scope.
  219. Returns (device_q, vm_q). Does NOT consider `is_active` — callers that need that should
  220. check it separately. For invalidation purposes, we want the scope set regardless of
  221. whether the context is currently active (toggling is_active also requires invalidation).
  222. `using` pins the scope lookups to the given database alias.
  223. """
  224. from extras.models.tags import TaggedItem
  225. def _nested_scope_q(m2m, object_path):
  226. # Match objects whose `object_path` ltree column is a descendant-or-equal of any node
  227. # selected in this nested-group m2m (regions, locations, etc.). This is the inverse of
  228. # the forward `<object>__path__ancestor_or_equal` match in ConfigContextQuerySet: there
  229. # a CC's node must be an ancestor of the object's node; here the object's node must fall
  230. # within a CC node's subtree. Returns None if the m2m is empty (no scope restriction).
  231. paths = list(m2m.using(using).values_list('path', flat=True))
  232. if not paths:
  233. return None
  234. q = Q()
  235. for path in paths:
  236. q |= Q(**{f'{object_path}__descendant_or_equal': path})
  237. return q
  238. def _direct_pks(m2m):
  239. pks = list(m2m.using(using).values_list('pk', flat=True))
  240. return pks or None
  241. # Shared filters (applicable to both Device and VirtualMachine)
  242. shared = Q()
  243. region_q = _nested_scope_q(self.regions, 'site__region__path')
  244. if region_q is not None:
  245. shared &= region_q
  246. site_group_q = _nested_scope_q(self.site_groups, 'site__group__path')
  247. if site_group_q is not None:
  248. shared &= site_group_q
  249. role_q = _nested_scope_q(self.roles, 'role__path')
  250. if role_q is not None:
  251. shared &= role_q
  252. platform_q = _nested_scope_q(self.platforms, 'platform__path')
  253. if platform_q is not None:
  254. shared &= platform_q
  255. for m2m, path in (
  256. (self.sites, 'site'),
  257. (self.cluster_types, 'cluster__type'),
  258. (self.cluster_groups, 'cluster__group'),
  259. (self.clusters, 'cluster'),
  260. (self.tenant_groups, 'tenant__group'),
  261. (self.tenants, 'tenant'),
  262. ):
  263. pks = _direct_pks(m2m)
  264. if pks is not None:
  265. shared &= Q(**{f'{path}__in': pks})
  266. # Tag-scoped contexts: object must be tagged with at least one of the context's tags
  267. tag_pks = _direct_pks(self.tags)
  268. device_q = Q(shared)
  269. vm_q = Q(shared)
  270. # Device-only filters: location (nested/ltree) and device_type (direct)
  271. location_q = _nested_scope_q(self.locations, 'location__path')
  272. if location_q is not None:
  273. device_q &= location_q
  274. device_type_pks = _direct_pks(self.device_types)
  275. if device_type_pks is not None:
  276. device_q &= Q(device_type__in=device_type_pks)
  277. # For VMs, locations and device_types must be empty for the context to apply
  278. if location_q is not None or device_type_pks is not None:
  279. vm_q &= Q(pk__in=())
  280. if tag_pks is not None:
  281. device_tagged = TaggedItem.objects.using(using).filter(
  282. tag_id__in=tag_pks,
  283. content_type__app_label='dcim',
  284. content_type__model='device',
  285. ).values_list('object_id', flat=True)
  286. vm_tagged = TaggedItem.objects.using(using).filter(
  287. tag_id__in=tag_pks,
  288. content_type__app_label='virtualization',
  289. content_type__model='virtualmachine',
  290. ).values_list('object_id', flat=True)
  291. device_q &= Q(pk__in=device_tagged)
  292. vm_q &= Q(pk__in=vm_tagged)
  293. return device_q, vm_q
  294. class ConfigContextModel(models.Model):
  295. """
  296. A model which includes local configuration context data. This local data will override any inherited data from
  297. ConfigContexts.
  298. """
  299. # Pre-rendered config context cache. NULL means "invalidated; render on demand". Populated by
  300. # extras.jobs.RenderConfigContextJob in the background.
  301. _config_context_data = models.JSONField(
  302. blank=True,
  303. null=True,
  304. editable=False,
  305. )
  306. # Monotonic counter bumped each time the cache is invalidated. The background renderer captures
  307. # this value before rendering and only writes the result back if it is unchanged, so a fresh
  308. # invalidation that lands mid-render is never overwritten by a stale value (compare-and-set).
  309. _config_context_generation = models.PositiveBigIntegerField(
  310. default=0,
  311. editable=False,
  312. )
  313. local_context_data = models.JSONField(
  314. blank=True,
  315. null=True,
  316. help_text=_(
  317. "Local config context data takes precedence over source contexts in the final rendered config context"
  318. )
  319. )
  320. class Meta:
  321. abstract = True
  322. def get_config_context(self, *, copy_data=True):
  323. """
  324. Return the merged config context for this object. If a pre-rendered cache is present
  325. (`_config_context_data`), return a copy of it unless copy_data is False. Otherwise, fall
  326. back to rendering on demand.
  327. By default, the returned dict is safe for callers to mutate (e.g. ObjectRenderConfigView merges
  328. in additional context with .update()): the cached blob is deep-copied so mutations cannot
  329. leak back into this instance's in-memory cache, matching the fresh-dict guarantee of the
  330. on-demand render path. Read-only callers may pass copy_data=False to skip the copy. The
  331. cached blob is then returned as is and must not be modified.
  332. """
  333. cached = getattr(self, '_config_context_data', None)
  334. if cached is not None:
  335. return copy.deepcopy(cached) if copy_data else cached
  336. return self.render_config_context()
  337. def render_config_context(self):
  338. """
  339. Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
  340. Return the rendered configuration context for a device or VM. This bypasses the pre-rendered cache
  341. (`_config_context_data`); use get_config_context() for the cached read path.
  342. """
  343. data = {}
  344. if not hasattr(self, 'config_context_data'):
  345. # The annotation is not available, so we fall back to manually querying for the config context objects
  346. config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True) or []
  347. else:
  348. # The attribute may exist, but the annotated value could be None if there is no config context data
  349. config_context_data = self.config_context_data or []
  350. for context in config_context_data:
  351. data = deepmerge(data, context)
  352. # If the object has local config context data defined, merge it last
  353. if self.local_context_data:
  354. data = deepmerge(data, self.local_context_data)
  355. return data
  356. def clean(self):
  357. super().clean()
  358. # Verify that JSON data is provided as an object
  359. if self.local_context_data is not None and type(self.local_context_data) is not dict:
  360. raise ValidationError(
  361. {'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
  362. )
  363. def serialize_object(self, exclude=None):
  364. # Exclude the pre-rendered cache and its generation counter from change-log snapshots;
  365. # they are derived fields and would otherwise produce noisy diffs.
  366. exclude = list(exclude or [])
  367. for field in ('_config_context_data', '_config_context_generation'):
  368. if field not in exclude:
  369. exclude.append(field)
  370. return super().serialize_object(exclude=exclude)
  371. #
  372. # Config templates
  373. #
  374. class ConfigTemplate(
  375. RenderTemplateMixin,
  376. SyncedDataMixin,
  377. CustomLinksMixin,
  378. ExportTemplatesMixin,
  379. OwnerMixin,
  380. TagsMixin,
  381. ChangeLoggedModel,
  382. ):
  383. name = models.CharField(
  384. verbose_name=_('name'),
  385. max_length=100
  386. )
  387. description = models.CharField(
  388. verbose_name=_('description'),
  389. max_length=200,
  390. blank=True
  391. )
  392. debug = models.BooleanField(
  393. verbose_name=_('debug'),
  394. default=False,
  395. help_text=_(
  396. 'Enable verbose error output when rendering this template. Not recommended for production use.'
  397. )
  398. )
  399. class Meta:
  400. ordering = ('name',)
  401. indexes = (
  402. models.Index(fields=('name',)), # Default ordering
  403. )
  404. verbose_name = _('config template')
  405. verbose_name_plural = _('config templates')
  406. def __str__(self):
  407. return self.name
  408. def get_absolute_url(self):
  409. return reverse('extras:configtemplate', args=[self.pk])
  410. def sync_data(self):
  411. """
  412. Synchronize template content from the designated DataFile (if any).
  413. """
  414. self.template_code = self.validate_synced_value('template_code', self.data_file.data_as_string)
  415. sync_data.alters_data = True
  416. def get_environment_params(self):
  417. """
  418. Config templates render plain text (network configs, scripts), not HTML. Force
  419. autoescape off so environment_params cannot enable it and create a latent XSS sink
  420. if output is ever rendered in an HTML context.
  421. """
  422. params = super().get_environment_params()
  423. params['autoescape'] = False
  424. return params
  425. def format_render_error(self, exc):
  426. """
  427. Return a formatted error string for a rendering exception. When debug is enabled, the full
  428. traceback for the provided exception is returned. Otherwise, a concise, user-facing message
  429. is returned.
  430. """
  431. if self.debug:
  432. # Strip deployment-specific path prefixes from File "..." lines to avoid disclosing
  433. # the server's filesystem layout. install_root covers all NetBox source files plus
  434. # any venv co-located inside the repo. When the venv lives outside the repo
  435. # (the typical production pattern, e.g. ~/.venv/netbox/), sys.prefix differs from
  436. # sys.base_prefix and the venv root is stripped separately so that the deployment
  437. # user's home directory is not exposed. Stdlib paths not under either prefix are
  438. # left as-is — they reveal only standard OS locations, not deployment structure.
  439. install_root = os.path.dirname(settings.BASE_DIR) + os.sep
  440. prefixes_to_strip = [install_root]
  441. if sys.prefix != sys.base_prefix:
  442. venv_root = sys.prefix + os.sep
  443. if venv_root != install_root:
  444. prefixes_to_strip.append(venv_root)
  445. tb = ''.join(traceback.format_exception(exc))
  446. for prefix in prefixes_to_strip:
  447. tb = re.sub(r'(File ")' + re.escape(prefix), r'\1', tb)
  448. return tb
  449. if isinstance(exc, TemplateError):
  450. parts = [f"{type(exc).__name__}: {exc}"]
  451. if getattr(exc, 'name', None):
  452. parts.append(_("Template: {name}").format(name=exc.name))
  453. if getattr(exc, 'lineno', None):
  454. parts.append(_("Line: {lineno}").format(lineno=exc.lineno))
  455. return "\n".join(parts)
  456. return f"{type(exc).__name__}: {exc}"