scripts.py 16 KB

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