jobs.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import logging
  2. import traceback
  3. from contextlib import ExitStack
  4. from django.apps import apps
  5. from django.db import DEFAULT_DB_ALIAS, router, transaction
  6. from django.utils.translation import gettext as _
  7. from django_pg_utils import advisory_lock
  8. from core.models import ObjectType
  9. from core.signals import clear_events
  10. from dcim.models import Device
  11. from extras.choices import CustomFieldStatusChoices
  12. from extras.constants import CUSTOMFIELD_JOB_TIMEOUT
  13. from extras.models import CustomField
  14. from extras.models import Script as ScriptModel
  15. from netbox.context_managers import event_tracking
  16. from netbox.jobs import JobRunner
  17. from netbox.registry import registry
  18. from utilities.exceptions import AbortScript, AbortTransaction
  19. from .utils import is_report
  20. __all__ = (
  21. 'CustomFieldDataJob',
  22. 'CustomFieldProvisioningJob',
  23. 'CustomFieldPurgeJob',
  24. 'RenderConfigContextJob',
  25. 'ScriptJob',
  26. 'provision_custom_field',
  27. 'purge_custom_field',
  28. )
  29. #
  30. # Config contexts
  31. #
  32. RENDER_CONFIG_CONTEXT_CHUNK_SIZE = 500
  33. # Safety bound on the number of re-scan passes performed by RenderConfigContextJob.run() (see the
  34. # loop there). Each pass re-queries for NULL caches, so any finite burst of concurrent
  35. # invalidations is drained well within this limit; the cap only guards against an object whose
  36. # cache is being invalidated faster than it can be rendered (pathological, unbounded churn).
  37. RENDER_CONFIG_CONTEXT_MAX_PASSES = 100
  38. class RenderConfigContextJob(JobRunner):
  39. """
  40. Recompute the pre-rendered `_config_context_data` cache for a set of Devices or
  41. VirtualMachines. Enqueued (coalesced) by the invalidation helpers in extras/cache.py whenever
  42. an upstream change (ConfigContext, related object, or the object itself) NULLs a cache.
  43. This is *not* a recurring system job: the initial post-upgrade population is handled by the
  44. `rebuild_config_context_cache` management command, and steady-state freshness is maintained by
  45. the invalidation signals.
  46. """
  47. class Meta:
  48. name = 'Render config context'
  49. def run(self, model_label=None, pks=None, **kwargs):
  50. """
  51. Args:
  52. model_label: 'dcim.device' or 'virtualization.virtualmachine'. If None, both are processed.
  53. pks: An iterable of object PKs to refresh. If None, refresh all objects whose cache is null.
  54. """
  55. labels = (model_label,) if model_label is not None else ('dcim.device', 'virtualization.virtualmachine')
  56. pks = list(pks) if pks is not None else None
  57. # Re-scan until a full pass renders nothing. An invalidation that commits while this job is
  58. # already RUNNING coalesces into this job — JobRunner.enqueue_once() treats RUNNING as an
  59. # enqueued state — so it will NOT schedule a follow-up job. If we rendered in a single pass,
  60. # any cache NULLed after the iterator moved past its row (or after the pass for its model
  61. # completed) would be left populated by no one, stranding it on the on-demand read path
  62. # indefinitely. Looping until a pass finds no renderable NULL caches guarantees those late
  63. # invalidations are picked up before this job finishes.
  64. total = 0
  65. for _pass in range(RENDER_CONFIG_CONTEXT_MAX_PASSES):
  66. rendered = sum(self._render_for_model(label, pks=pks) for label in labels)
  67. total += rendered
  68. # No progress this pass means either nothing is NULL or the only NULL rows are churning
  69. # under concurrent invalidation (each such invalidation enqueues its own follow-up), so
  70. # there is nothing more for us to safely do.
  71. if not rendered:
  72. break
  73. else:
  74. # The loop ran every pass without ever rendering nothing, meaning caches are being
  75. # invalidated about as fast as we can render them. This is pathological churn worth
  76. # surfacing: each lingering invalidation enqueues its own follow-up job, so the caches
  77. # are not stranded, but the sustained rate warrants investigation.
  78. self.logger.warning(
  79. f"Reached the maximum of {RENDER_CONFIG_CONTEXT_MAX_PASSES} render passes with caches "
  80. f"still being invalidated; config context caches may be churning under sustained "
  81. f"concurrent invalidation."
  82. )
  83. self.logger.info(f"Rendered config context for {total} object(s)")
  84. def _render_for_model(self, model_label, pks):
  85. """
  86. Render and cache config context for every object of the given model whose cache is
  87. currently NULL (optionally restricted to `pks`). Returns the number of objects written.
  88. """
  89. Model = apps.get_model(model_label)
  90. qs = Model.objects.filter(_config_context_data__isnull=True)
  91. if pks is not None:
  92. qs = qs.filter(pk__in=list(pks))
  93. # Annotate so each instance's render() uses the same aggregated subquery the on-demand
  94. # path would use, avoiding N additional queries.
  95. qs = qs.annotate_config_context_data()
  96. rendered = 0
  97. for obj in qs.iterator(chunk_size=RENDER_CONFIG_CONTEXT_CHUNK_SIZE):
  98. # Capture the generation we rendered against, then write the result back only if no
  99. # invalidation has bumped it in the meantime (compare-and-set). If a fresh invalidation
  100. # won the race, the row stays NULL with a higher generation and the follow-up job it
  101. # enqueued will re-render it — we never persist a stale value.
  102. generation = obj._config_context_generation
  103. data = obj.render_config_context()
  104. updated = Model.objects.filter(
  105. pk=obj.pk,
  106. _config_context_generation=generation,
  107. ).update(_config_context_data=data)
  108. rendered += updated
  109. return rendered
  110. #
  111. # Custom fields
  112. #
  113. def provision_custom_field(pk, object_type_pks):
  114. """
  115. Populate a new custom field's default value across the objects of the given types, then bring
  116. the field live. Returns True if the field was brought live.
  117. The backfill is committed in batches, so an interruption leaves the field provisioning with some
  118. of its objects already updated. Running again completes it.
  119. Args:
  120. pk: The primary key of the CustomField to provision
  121. object_type_pks: The primary keys of the object types to provision. Named explicitly, as
  122. only the caller which deferred the work knows which of the field's assignments are the
  123. new ones.
  124. """
  125. # Taken on the connection the field is written on, as CustomField.delete() takes it, so that
  126. # the two are actually exclusive of one another.
  127. using = router.db_for_write(CustomField)
  128. with advisory_lock(CustomField.data_lock_key(pk), using=using):
  129. # Rechecked now that the lock is held: where two jobs were enqueued for the same field,
  130. # whichever arrived first has left it in a state the other no longer matches.
  131. custom_field = CustomField.objects.filter(pk=pk, status=CustomFieldStatusChoices.STATUS_PROVISIONING).first()
  132. if custom_field is None:
  133. return False
  134. object_types = ObjectType.objects.filter(pk__in=object_type_pks)
  135. custom_field.populate_initial_data(object_types, commit_per_batch=True)
  136. # Applied via the queryset so that bringing the field live does not record a change of its
  137. # own, and cannot trip the guard in CustomField.clean().
  138. activated = CustomField.objects.filter(
  139. pk=pk, status=CustomFieldStatusChoices.STATUS_PROVISIONING
  140. ).update(status=CustomFieldStatusChoices.STATUS_ACTIVE)
  141. return bool(activated)
  142. def purge_custom_field(pk):
  143. """
  144. Remove a deleted custom field's data from all applicable objects, then remove the field itself.
  145. Returns True if the field was purged.
  146. The row is dropped only once its data is gone: until then it reserves the field's name against a
  147. new field which would otherwise inherit the orphaned values. The removal is committed in batches,
  148. so an interruption leaves data behind for a later run to finish removing.
  149. Args:
  150. pk: The primary key of the CustomField to purge
  151. """
  152. # Taken on the connection the field is written on, as CustomField.delete() takes it, so that
  153. # the two are actually exclusive of one another.
  154. using = router.db_for_write(CustomField)
  155. with advisory_lock(CustomField.data_lock_key(pk), using=using):
  156. # Rechecked now that the lock is held: where two jobs were enqueued for the same field,
  157. # whichever arrived first has left it in a state the other no longer matches.
  158. custom_field = CustomField.objects.filter(pk=pk, status=CustomFieldStatusChoices.STATUS_DELETING).first()
  159. if custom_field is None:
  160. return False
  161. custom_field.remove_stale_data(custom_field.object_types.all(), commit_per_batch=True)
  162. custom_field._delete_row()
  163. return True
  164. class CustomFieldDataJob(JobRunner):
  165. """
  166. Base class for the jobs which rewrite a custom field's stored data in bulk.
  167. The field is passed by primary key rather than assigned to the job as its object. Job.clean()
  168. permits only models with the jobs feature there, and granting CustomField that feature would
  169. give it a cascading relation to its jobs -- so the purge job, whose last act is to remove the
  170. row, would delete the record of its own execution as it ran.
  171. """
  172. @classmethod
  173. def enqueue_for(cls, custom_field, **kwargs):
  174. """
  175. Enqueue this job for the given custom field, naming the field in the job's name and raising
  176. its timeout from the default (see CUSTOMFIELD_JOB_TIMEOUT).
  177. """
  178. return cls.enqueue(
  179. name=f'{cls.name}: {custom_field}',
  180. custom_field_pk=custom_field.pk,
  181. job_timeout=CUSTOMFIELD_JOB_TIMEOUT,
  182. **kwargs,
  183. )
  184. class CustomFieldProvisioningJob(CustomFieldDataJob):
  185. """
  186. Populate the default value of a newly created custom field.
  187. """
  188. class Meta:
  189. name = 'Custom Field Provisioning'
  190. def run(self, custom_field_pk, *args, object_type_pks, **kwargs):
  191. if provision_custom_field(custom_field_pk, object_type_pks):
  192. self.logger.info("Custom field provisioned")
  193. else:
  194. self.logger.info("Custom field is no longer awaiting provisioning; skipping")
  195. class CustomFieldPurgeJob(CustomFieldDataJob):
  196. """
  197. Purge the stored data of a deleted custom field, then delete the field.
  198. """
  199. class Meta:
  200. name = 'Custom Field Purge'
  201. def run(self, custom_field_pk, *args, **kwargs):
  202. if purge_custom_field(custom_field_pk):
  203. self.logger.info("Custom field data purged")
  204. else:
  205. self.logger.info("Custom field is no longer awaiting deletion; skipping")
  206. #
  207. # Scripts
  208. #
  209. class ScriptJob(JobRunner):
  210. """
  211. Script execution job.
  212. A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
  213. exists outside the Script class to ensure it cannot be overridden by a script author.
  214. """
  215. class Meta:
  216. name = 'Run Script'
  217. def run_script(self, script, request, data, commit):
  218. """
  219. Core script execution task. We capture this within a method to allow for conditionally wrapping it with the
  220. event_tracking context manager (which is bypassed if commit == False).
  221. Args:
  222. request: The WSGI request associated with this execution (if any)
  223. data: A dictionary of data to be passed to the script upon execution
  224. commit: Passed through to Script.run()
  225. """
  226. logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
  227. logger.info(f"Running script (commit={commit})")
  228. try:
  229. try:
  230. # A script can modify multiple models so need to do an atomic lock on
  231. # both the default database (for non ChangeLogged models) and potentially
  232. # any other database (for ChangeLogged models)
  233. changeloged_db = router.db_for_write(Device)
  234. with transaction.atomic(using=DEFAULT_DB_ALIAS):
  235. # If branch database is different from default, wrap in a second atomic transaction
  236. # Note: Don't add any extra code between the two atomic transactions,
  237. # otherwise the changes might get committed to the default database
  238. # if there are any raised exceptions.
  239. if changeloged_db != DEFAULT_DB_ALIAS:
  240. with transaction.atomic(using=changeloged_db):
  241. script.output = script.run(data, commit)
  242. if not commit:
  243. raise AbortTransaction()
  244. else:
  245. script.output = script.run(data, commit)
  246. if not commit:
  247. raise AbortTransaction()
  248. except AbortTransaction:
  249. script.log_info(message=_("Database changes have been reverted automatically."))
  250. if script.failed:
  251. logger.warning("Script failed")
  252. except Exception as e:
  253. if type(e) is AbortScript:
  254. msg = _("Script aborted with error: ") + str(e)
  255. if is_report(type(script)):
  256. script.log_failure(message=msg)
  257. else:
  258. script.log_failure(msg)
  259. logger.error(f"Script aborted with error: {e}")
  260. self.logger.error(f"Script aborted with error: {e}")
  261. else:
  262. stacktrace = traceback.format_exc()
  263. script.log_failure(
  264. message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
  265. )
  266. logger.error(f"Exception raised during script execution: {e}")
  267. self.logger.error(f"Exception raised during script execution: {e}")
  268. if type(e) is not AbortTransaction:
  269. script.log_info(message=_("Database changes have been reverted due to error."))
  270. self.logger.info("Database changes have been reverted due to error.")
  271. # Clear all pending events. Job termination (including setting the status) is handled by the job framework.
  272. if request:
  273. clear_events.send(request)
  274. raise
  275. # Update the job data regardless of the execution status of the job. Successes should be reported as well as
  276. # failures.
  277. finally:
  278. self.job.data = script.get_job_data()
  279. def run(self, data, request=None, commit=True, **kwargs):
  280. """
  281. Run the script.
  282. Args:
  283. job: The Job associated with this execution
  284. data: A dictionary of data to be passed to the script upon execution
  285. request: The WSGI request associated with this execution (if any)
  286. commit: Passed through to Script.run()
  287. """
  288. script_model = ScriptModel.objects.get(pk=self.job.object_id)
  289. self.logger.debug(f"Found ScriptModel ID {script_model.pk}")
  290. script = script_model.python_class()
  291. self.logger.debug(f"Loaded script {script.full_name}")
  292. # Add files to form data
  293. if request:
  294. files = request.FILES
  295. for field_name, fileobj in files.items():
  296. data[field_name] = fileobj
  297. # Add the current request as a property of the script
  298. script.request = request
  299. self.logger.debug(f"Request ID: {request.id if request else None}")
  300. if commit:
  301. self.logger.info("Executing script (commit enabled)")
  302. else:
  303. self.logger.warning("Executing script (commit disabled)")
  304. with ExitStack() as stack:
  305. for request_processor in registry['request_processors']:
  306. if not commit and request_processor is event_tracking:
  307. continue
  308. stack.enter_context(request_processor(request))
  309. self.run_script(script, request, data, commit)