scripts.py 15 KB

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