scripts.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import inspect
  2. import json
  3. import logging
  4. import os
  5. import traceback
  6. from datetime import timedelta
  7. import yaml
  8. from django import forms
  9. from django.conf import settings
  10. from django.core.validators import RegexValidator
  11. from django.db import transaction
  12. from django.utils.functional import classproperty
  13. from django.utils.translation import gettext as _
  14. from core.choices import JobStatusChoices
  15. from core.models import Job
  16. from extras.api.serializers import ScriptOutputSerializer
  17. from extras.choices import LogLevelChoices
  18. from extras.models import ScriptModule
  19. from extras.signals import clear_events
  20. from ipam.formfields import IPAddressFormField, IPNetworkFormField
  21. from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
  22. from utilities.exceptions import AbortScript, AbortTransaction
  23. from utilities.forms import add_blank_choice
  24. from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
  25. from .context_managers import event_tracking
  26. from .forms import ScriptForm
  27. __all__ = (
  28. 'BaseScript',
  29. 'BooleanVar',
  30. 'ChoiceVar',
  31. 'FileVar',
  32. 'IntegerVar',
  33. 'IPAddressVar',
  34. 'IPAddressWithMaskVar',
  35. 'IPNetworkVar',
  36. 'MultiChoiceVar',
  37. 'MultiObjectVar',
  38. 'ObjectVar',
  39. 'Script',
  40. 'StringVar',
  41. 'TextVar',
  42. 'get_module_and_script',
  43. 'run_script',
  44. )
  45. #
  46. # Script variables
  47. #
  48. class ScriptVariable:
  49. """
  50. Base model for script variables
  51. """
  52. form_field = forms.CharField
  53. def __init__(self, label='', description='', default=None, required=True, widget=None):
  54. # Initialize field attributes
  55. if not hasattr(self, 'field_attrs'):
  56. self.field_attrs = {}
  57. if label:
  58. self.field_attrs['label'] = label
  59. if description:
  60. self.field_attrs['help_text'] = description
  61. if default:
  62. self.field_attrs['initial'] = default
  63. if widget:
  64. self.field_attrs['widget'] = widget
  65. self.field_attrs['required'] = required
  66. def as_field(self):
  67. """
  68. Render the variable as a Django form field.
  69. """
  70. form_field = self.form_field(**self.field_attrs)
  71. if not isinstance(form_field.widget, forms.CheckboxInput):
  72. if form_field.widget.attrs and 'class' in form_field.widget.attrs.keys():
  73. form_field.widget.attrs['class'] += ' form-control'
  74. else:
  75. form_field.widget.attrs['class'] = 'form-control'
  76. return form_field
  77. class StringVar(ScriptVariable):
  78. """
  79. Character string representation. Can enforce minimum/maximum length and/or regex validation.
  80. """
  81. def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs):
  82. super().__init__(*args, **kwargs)
  83. # Optional minimum/maximum lengths
  84. if min_length:
  85. self.field_attrs['min_length'] = min_length
  86. if max_length:
  87. self.field_attrs['max_length'] = max_length
  88. # Optional regular expression validation
  89. if regex:
  90. self.field_attrs['validators'] = [
  91. RegexValidator(
  92. regex=regex,
  93. message='Invalid value. Must match regex: {}'.format(regex),
  94. code='invalid'
  95. )
  96. ]
  97. class TextVar(ScriptVariable):
  98. """
  99. Free-form text data. Renders as a <textarea>.
  100. """
  101. form_field = forms.CharField
  102. def __init__(self, *args, **kwargs):
  103. super().__init__(*args, **kwargs)
  104. self.field_attrs['widget'] = forms.Textarea
  105. class IntegerVar(ScriptVariable):
  106. """
  107. Integer representation. Can enforce minimum/maximum values.
  108. """
  109. form_field = forms.IntegerField
  110. def __init__(self, min_value=None, max_value=None, *args, **kwargs):
  111. super().__init__(*args, **kwargs)
  112. # Optional minimum/maximum values
  113. if min_value:
  114. self.field_attrs['min_value'] = min_value
  115. if max_value:
  116. self.field_attrs['max_value'] = max_value
  117. class BooleanVar(ScriptVariable):
  118. """
  119. Boolean representation (true/false). Renders as a checkbox.
  120. """
  121. form_field = forms.BooleanField
  122. def __init__(self, *args, **kwargs):
  123. super().__init__(*args, **kwargs)
  124. # Boolean fields cannot be required
  125. self.field_attrs['required'] = False
  126. class ChoiceVar(ScriptVariable):
  127. """
  128. Select one of several predefined static choices, passed as a list of two-tuples. Example:
  129. color = ChoiceVar(
  130. choices=(
  131. ('#ff0000', 'Red'),
  132. ('#00ff00', 'Green'),
  133. ('#0000ff', 'Blue')
  134. )
  135. )
  136. """
  137. form_field = forms.ChoiceField
  138. def __init__(self, choices, *args, **kwargs):
  139. super().__init__(*args, **kwargs)
  140. # Set field choices, adding a blank choice to avoid forced selections
  141. self.field_attrs['choices'] = add_blank_choice(choices)
  142. class MultiChoiceVar(ScriptVariable):
  143. """
  144. Like ChoiceVar, but allows for the selection of multiple choices.
  145. """
  146. form_field = forms.MultipleChoiceField
  147. def __init__(self, choices, *args, **kwargs):
  148. super().__init__(*args, **kwargs)
  149. # Set field choices
  150. self.field_attrs['choices'] = choices
  151. class ObjectVar(ScriptVariable):
  152. """
  153. A single object within NetBox.
  154. :param model: The NetBox model being referenced
  155. :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
  156. :param null_option: The label to use as a "null" selection option (optional)
  157. """
  158. form_field = DynamicModelChoiceField
  159. def __init__(self, model, query_params=None, null_option=None, *args, **kwargs):
  160. super().__init__(*args, **kwargs)
  161. self.field_attrs.update({
  162. 'queryset': model.objects.all(),
  163. 'query_params': query_params,
  164. 'null_option': null_option,
  165. })
  166. class MultiObjectVar(ObjectVar):
  167. """
  168. Like ObjectVar, but can represent one or more objects.
  169. """
  170. form_field = DynamicModelMultipleChoiceField
  171. class FileVar(ScriptVariable):
  172. """
  173. An uploaded file.
  174. """
  175. form_field = forms.FileField
  176. class IPAddressVar(ScriptVariable):
  177. """
  178. An IPv4 or IPv6 address without a mask.
  179. """
  180. form_field = IPAddressFormField
  181. class IPAddressWithMaskVar(ScriptVariable):
  182. """
  183. An IPv4 or IPv6 address with a mask.
  184. """
  185. form_field = IPNetworkFormField
  186. class IPNetworkVar(ScriptVariable):
  187. """
  188. An IPv4 or IPv6 prefix.
  189. """
  190. form_field = IPNetworkFormField
  191. def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
  192. super().__init__(*args, **kwargs)
  193. # Set prefix validator and optional minimum/maximum prefix lengths
  194. self.field_attrs['validators'] = [prefix_validator]
  195. if min_prefix_length is not None:
  196. self.field_attrs['validators'].append(
  197. MinPrefixLengthValidator(min_prefix_length)
  198. )
  199. if max_prefix_length is not None:
  200. self.field_attrs['validators'].append(
  201. MaxPrefixLengthValidator(max_prefix_length)
  202. )
  203. #
  204. # Scripts
  205. #
  206. class BaseScript:
  207. """
  208. Base model for custom scripts. User classes should inherit from this model if they want to extend Script
  209. functionality for use in other subclasses.
  210. """
  211. # Prevent django from instantiating the class on all accesses
  212. do_not_call_in_templates = True
  213. class Meta:
  214. pass
  215. def __init__(self):
  216. # Initiate the log
  217. self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}")
  218. self.log = []
  219. # Declare the placeholder for the current request
  220. self.request = None
  221. # Grab some info about the script
  222. self.filename = inspect.getfile(self.__class__)
  223. self.source = inspect.getsource(self.__class__)
  224. def __str__(self):
  225. return self.name
  226. @classproperty
  227. def module(self):
  228. return self.__module__
  229. @classproperty
  230. def class_name(self):
  231. return self.__name__
  232. @classproperty
  233. def full_name(self):
  234. return f'{self.module}.{self.class_name}'
  235. @classmethod
  236. def root_module(cls):
  237. return cls.__module__.split(".")[0]
  238. # Author-defined attributes
  239. @classproperty
  240. def name(self):
  241. return getattr(self.Meta, 'name', self.__name__)
  242. @classproperty
  243. def description(self):
  244. return getattr(self.Meta, 'description', '')
  245. @classproperty
  246. def field_order(self):
  247. return getattr(self.Meta, 'field_order', None)
  248. @classproperty
  249. def fieldsets(self):
  250. return getattr(self.Meta, 'fieldsets', None)
  251. @classproperty
  252. def commit_default(self):
  253. return getattr(self.Meta, 'commit_default', True)
  254. @classproperty
  255. def job_timeout(self):
  256. return getattr(self.Meta, 'job_timeout', None)
  257. @classproperty
  258. def scheduling_enabled(self):
  259. return getattr(self.Meta, 'scheduling_enabled', True)
  260. @classmethod
  261. def _get_vars(cls):
  262. vars = {}
  263. # Iterate all base classes looking for ScriptVariables
  264. for base_class in inspect.getmro(cls):
  265. # When object is reached there's no reason to continue
  266. if base_class is object:
  267. break
  268. for name, attr in base_class.__dict__.items():
  269. if name not in vars and issubclass(attr.__class__, ScriptVariable):
  270. vars[name] = attr
  271. # Order variables according to field_order
  272. if not cls.field_order:
  273. return vars
  274. ordered_vars = {
  275. field: vars.pop(field) for field in cls.field_order if field in vars
  276. }
  277. ordered_vars.update(vars)
  278. return ordered_vars
  279. def run(self, data, commit):
  280. raise NotImplementedError(_("The script must define a run() method."))
  281. # Form rendering
  282. def get_fieldsets(self):
  283. fieldsets = []
  284. if self.fieldsets:
  285. fieldsets.extend(self.fieldsets)
  286. else:
  287. fields = list(name for name, _ in self._get_vars().items())
  288. fieldsets.append((_('Script Data'), fields))
  289. # Append the default fieldset if defined in the Meta class
  290. exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
  291. fieldsets.append((_('Script Execution Parameters'), exec_parameters))
  292. return fieldsets
  293. def as_form(self, data=None, files=None, initial=None):
  294. """
  295. Return a Django form suitable for populating the context data required to run this Script.
  296. """
  297. # Create a dynamic ScriptForm subclass from script variables
  298. fields = {
  299. name: var.as_field() for name, var in self._get_vars().items()
  300. }
  301. FormClass = type('ScriptForm', (ScriptForm,), fields)
  302. form = FormClass(data, files, initial=initial)
  303. # Set initial "commit" checkbox state based on the script's Meta parameter
  304. form.fields['_commit'].initial = self.commit_default
  305. # Hide fields if scheduling has been disabled
  306. if not self.scheduling_enabled:
  307. form.fields['_schedule_at'].widget = forms.HiddenInput()
  308. form.fields['_interval'].widget = forms.HiddenInput()
  309. return form
  310. # Logging
  311. def log_debug(self, message):
  312. self.logger.log(logging.DEBUG, message)
  313. self.log.append((LogLevelChoices.LOG_DEFAULT, str(message)))
  314. def log_success(self, message):
  315. self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS
  316. self.log.append((LogLevelChoices.LOG_SUCCESS, str(message)))
  317. def log_info(self, message):
  318. self.logger.log(logging.INFO, message)
  319. self.log.append((LogLevelChoices.LOG_INFO, str(message)))
  320. def log_warning(self, message):
  321. self.logger.log(logging.WARNING, message)
  322. self.log.append((LogLevelChoices.LOG_WARNING, str(message)))
  323. def log_failure(self, message):
  324. self.logger.log(logging.ERROR, message)
  325. self.log.append((LogLevelChoices.LOG_FAILURE, str(message)))
  326. # Convenience functions
  327. def load_yaml(self, filename):
  328. """
  329. Return data from a YAML file
  330. """
  331. try:
  332. from yaml import CLoader as Loader
  333. except ImportError:
  334. from yaml import Loader
  335. file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
  336. with open(file_path, 'r') as datafile:
  337. data = yaml.load(datafile, Loader=Loader)
  338. return data
  339. def load_json(self, filename):
  340. """
  341. Return data from a JSON file
  342. """
  343. file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
  344. with open(file_path, 'r') as datafile:
  345. data = json.load(datafile)
  346. return data
  347. class Script(BaseScript):
  348. """
  349. Classes which inherit this model will appear in the list of available scripts.
  350. """
  351. pass
  352. #
  353. # Functions
  354. #
  355. def is_variable(obj):
  356. """
  357. Returns True if the object is a ScriptVariable.
  358. """
  359. return isinstance(obj, ScriptVariable)
  360. def get_module_and_script(module_name, script_name):
  361. module = ScriptModule.objects.get(file_path=f'{module_name}.py')
  362. script = module.scripts.get(script_name)
  363. return module, script
  364. def run_script(data, job, request=None, commit=True, **kwargs):
  365. """
  366. A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
  367. exists outside the Script class to ensure it cannot be overridden by a script author.
  368. Args:
  369. data: A dictionary of data to be passed to the script upon execution
  370. job: The Job associated with this execution
  371. request: The WSGI request associated with this execution (if any)
  372. commit: Passed through to Script.run()
  373. """
  374. job.start()
  375. module = ScriptModule.objects.get(pk=job.object_id)
  376. script = module.scripts.get(job.name)()
  377. logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
  378. logger.info(f"Running script (commit={commit})")
  379. # Add files to form data
  380. if request:
  381. files = request.FILES
  382. for field_name, fileobj in files.items():
  383. data[field_name] = fileobj
  384. # Add the current request as a property of the script
  385. script.request = request
  386. def _run_script():
  387. """
  388. Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
  389. the event_tracking context manager (which is bypassed if commit == False).
  390. """
  391. try:
  392. try:
  393. with transaction.atomic():
  394. script.output = script.run(data=data, commit=commit)
  395. if not commit:
  396. raise AbortTransaction()
  397. except AbortTransaction:
  398. script.log_info("Database changes have been reverted automatically.")
  399. if request:
  400. clear_events.send(request)
  401. job.data = ScriptOutputSerializer(script).data
  402. job.terminate()
  403. except Exception as e:
  404. if type(e) is AbortScript:
  405. script.log_failure(f"Script aborted with error: {e}")
  406. logger.error(f"Script aborted with error: {e}")
  407. else:
  408. stacktrace = traceback.format_exc()
  409. script.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```")
  410. logger.error(f"Exception raised during script execution: {e}")
  411. script.log_info("Database changes have been reverted due to error.")
  412. job.data = ScriptOutputSerializer(script).data
  413. job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
  414. if request:
  415. clear_events.send(request)
  416. logger.info(f"Script completed in {job.duration}")
  417. # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
  418. # change logging, event rules, etc.
  419. if commit:
  420. with event_tracking(request):
  421. _run_script()
  422. else:
  423. _run_script()
  424. # Schedule the next job if an interval has been set
  425. if job.interval:
  426. new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)
  427. Job.enqueue(
  428. run_script,
  429. instance=job.object,
  430. name=job.name,
  431. user=job.user,
  432. schedule_at=new_scheduled_time,
  433. interval=job.interval,
  434. job_timeout=script.job_timeout,
  435. data=data,
  436. request=request,
  437. commit=commit
  438. )