jobs.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. import logging
  2. import traceback
  3. from contextlib import ExitStack
  4. from django.db import router, transaction
  5. from django.db import DEFAULT_DB_ALIAS
  6. from django.utils.translation import gettext as _
  7. from core.signals import clear_events
  8. from dcim.models import Device
  9. from extras.models import Script as ScriptModel
  10. from netbox.context_managers import event_tracking
  11. from netbox.jobs import JobRunner
  12. from netbox.registry import registry
  13. from utilities.exceptions import AbortScript, AbortTransaction
  14. from .utils import is_report
  15. class ScriptJob(JobRunner):
  16. """
  17. Script execution job.
  18. A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
  19. exists outside the Script class to ensure it cannot be overridden by a script author.
  20. """
  21. class Meta:
  22. name = 'Run Script'
  23. def run_script(self, script, request, data, commit):
  24. """
  25. Core script execution task. We capture this within a method to allow for conditionally wrapping it with the
  26. event_tracking context manager (which is bypassed if commit == False).
  27. Args:
  28. request: The WSGI request associated with this execution (if any)
  29. data: A dictionary of data to be passed to the script upon execution
  30. commit: Passed through to Script.run()
  31. """
  32. logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
  33. logger.info(f"Running script (commit={commit})")
  34. try:
  35. try:
  36. # A script can modify multiple models so need to do an atomic lock on
  37. # both the default database (for non ChangeLogged models) and potentially
  38. # any other database (for ChangeLogged models)
  39. changeloged_db = router.db_for_write(Device)
  40. with transaction.atomic(using=DEFAULT_DB_ALIAS):
  41. # If branch database is different from default, wrap in a second atomic transaction
  42. # Note: Don't add any extra code between the two atomic transactions,
  43. # otherwise the changes might get committed to the default database
  44. # if there are any raised exceptions.
  45. if changeloged_db != DEFAULT_DB_ALIAS:
  46. with transaction.atomic(using=changeloged_db):
  47. script.output = script.run(data, commit)
  48. if not commit:
  49. raise AbortTransaction()
  50. else:
  51. script.output = script.run(data, commit)
  52. if not commit:
  53. raise AbortTransaction()
  54. except AbortTransaction:
  55. script.log_info(message=_("Database changes have been reverted automatically."))
  56. if script.failed:
  57. logger.warning("Script failed")
  58. except Exception as e:
  59. if type(e) is AbortScript:
  60. msg = _("Script aborted with error: ") + str(e)
  61. if is_report(type(script)):
  62. script.log_failure(message=msg)
  63. else:
  64. script.log_failure(msg)
  65. logger.error(f"Script aborted with error: {e}")
  66. self.logger.error(f"Script aborted with error: {e}")
  67. else:
  68. stacktrace = traceback.format_exc()
  69. script.log_failure(
  70. message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
  71. )
  72. logger.error(f"Exception raised during script execution: {e}")
  73. self.logger.error(f"Exception raised during script execution: {e}")
  74. if type(e) is not AbortTransaction:
  75. script.log_info(message=_("Database changes have been reverted due to error."))
  76. self.logger.info("Database changes have been reverted due to error.")
  77. # Clear all pending events. Job termination (including setting the status) is handled by the job framework.
  78. if request:
  79. clear_events.send(request)
  80. raise
  81. # Update the job data regardless of the execution status of the job. Successes should be reported as well as
  82. # failures.
  83. finally:
  84. self.job.data = script.get_job_data()
  85. def run(self, data, request=None, commit=True, **kwargs):
  86. """
  87. Run the script.
  88. Args:
  89. job: The Job associated with this execution
  90. data: A dictionary of data to be passed to the script upon execution
  91. request: The WSGI request associated with this execution (if any)
  92. commit: Passed through to Script.run()
  93. """
  94. script_model = ScriptModel.objects.get(pk=self.job.object_id)
  95. self.logger.debug(f"Found ScriptModel ID {script_model.pk}")
  96. script = script_model.python_class()
  97. self.logger.debug(f"Loaded script {script.full_name}")
  98. # Add files to form data
  99. if request:
  100. files = request.FILES
  101. for field_name, fileobj in files.items():
  102. data[field_name] = fileobj
  103. # Add the current request as a property of the script
  104. script.request = request
  105. self.logger.debug(f"Request ID: {request.id if request else None}")
  106. if commit:
  107. self.logger.info("Executing script (commit enabled)")
  108. else:
  109. self.logger.warning("Executing script (commit disabled)")
  110. with ExitStack() as stack:
  111. for request_processor in registry['request_processors']:
  112. if not commit and request_processor is event_tracking:
  113. continue
  114. stack.enter_context(request_processor(request))
  115. self.run_script(script, request, data, commit)