scripts.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. import inspect
  2. import json
  3. import logging
  4. import os
  5. import pkgutil
  6. import traceback
  7. import warnings
  8. from collections import OrderedDict
  9. import yaml
  10. from django import forms
  11. from django.conf import settings
  12. from django.core.validators import RegexValidator
  13. from django.db import transaction
  14. from django.utils.functional import classproperty
  15. from django_rq import job
  16. from extras.api.serializers import ScriptOutputSerializer
  17. from extras.choices import JobResultStatusChoices, LogLevelChoices
  18. from extras.models import JobResult
  19. from ipam.formfields import IPAddressFormField, IPNetworkFormField
  20. from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
  21. from utilities.exceptions import AbortTransaction
  22. from utilities.forms import 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
  137. self.field_attrs['choices'] = choices
  138. class MultiChoiceVar(ChoiceVar):
  139. """
  140. Like ChoiceVar, but allows for the selection of multiple choices.
  141. """
  142. form_field = forms.MultipleChoiceField
  143. class ObjectVar(ScriptVariable):
  144. """
  145. A single object within NetBox.
  146. :param model: The NetBox model being referenced
  147. :param display_field: The attribute of the returned object to display in the selection list (DEPRECATED)
  148. :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
  149. :param null_option: The label to use as a "null" selection option (optional)
  150. """
  151. form_field = DynamicModelChoiceField
  152. def __init__(self, model=None, queryset=None, query_params=None, null_option=None, *args, **kwargs):
  153. # TODO: Remove display_field in v2.12
  154. if 'display_field' in kwargs:
  155. warnings.warn(
  156. "The 'display_field' parameter has been deprecated, and will be removed in NetBox v2.12. Object "
  157. "variables will now reference the 'display' attribute available on all model serializers by default."
  158. )
  159. display_field = kwargs.pop('display_field', 'display')
  160. super().__init__(*args, **kwargs)
  161. # Set the form field's queryset. Support backward compatibility for the "queryset" argument for now.
  162. if model is not None:
  163. self.field_attrs['queryset'] = model.objects.all()
  164. elif queryset is not None:
  165. warnings.warn(
  166. f'{self}: Specifying a queryset for ObjectVar is no longer supported. Please use "model" instead.'
  167. )
  168. self.field_attrs['queryset'] = queryset
  169. else:
  170. raise TypeError('ObjectVar must specify a model')
  171. self.field_attrs.update({
  172. 'display_field': display_field,
  173. 'query_params': query_params,
  174. 'null_option': null_option,
  175. })
  176. class MultiObjectVar(ObjectVar):
  177. """
  178. Like ObjectVar, but can represent one or more objects.
  179. """
  180. form_field = DynamicModelMultipleChoiceField
  181. class FileVar(ScriptVariable):
  182. """
  183. An uploaded file.
  184. """
  185. form_field = forms.FileField
  186. class IPAddressVar(ScriptVariable):
  187. """
  188. An IPv4 or IPv6 address without a mask.
  189. """
  190. form_field = IPAddressFormField
  191. class IPAddressWithMaskVar(ScriptVariable):
  192. """
  193. An IPv4 or IPv6 address with a mask.
  194. """
  195. form_field = IPNetworkFormField
  196. class IPNetworkVar(ScriptVariable):
  197. """
  198. An IPv4 or IPv6 prefix.
  199. """
  200. form_field = IPNetworkFormField
  201. def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
  202. super().__init__(*args, **kwargs)
  203. # Set prefix validator and optional minimum/maximum prefix lengths
  204. self.field_attrs['validators'] = [prefix_validator]
  205. if min_prefix_length is not None:
  206. self.field_attrs['validators'].append(
  207. MinPrefixLengthValidator(min_prefix_length)
  208. )
  209. if max_prefix_length is not None:
  210. self.field_attrs['validators'].append(
  211. MaxPrefixLengthValidator(max_prefix_length)
  212. )
  213. #
  214. # Scripts
  215. #
  216. class BaseScript:
  217. """
  218. Base model for custom scripts. User classes should inherit from this model if they want to extend Script
  219. functionality for use in other subclasses.
  220. """
  221. class Meta:
  222. pass
  223. def __init__(self):
  224. # Initiate the log
  225. self.logger = logging.getLogger(f"netbox.scripts.{self.module()}.{self.__class__.__name__}")
  226. self.log = []
  227. # Declare the placeholder for the current request
  228. self.request = None
  229. # Grab some info about the script
  230. self.filename = inspect.getfile(self.__class__)
  231. self.source = inspect.getsource(self.__class__)
  232. def __str__(self):
  233. return self.name
  234. @classproperty
  235. def name(self):
  236. return getattr(self.Meta, 'name', self.__class__.__name__)
  237. @classproperty
  238. def full_name(self):
  239. return '.'.join([self.__module__, self.__name__])
  240. @classproperty
  241. def description(self):
  242. return getattr(self.Meta, 'description', '')
  243. @classmethod
  244. def module(cls):
  245. return cls.__module__
  246. @classmethod
  247. def _get_vars(cls):
  248. vars = OrderedDict()
  249. for name, attr in cls.__dict__.items():
  250. if name not in vars and issubclass(attr.__class__, ScriptVariable):
  251. vars[name] = attr
  252. return vars
  253. def run(self, data, commit):
  254. raise NotImplementedError("The script must define a run() method.")
  255. def as_form(self, data=None, files=None, initial=None):
  256. """
  257. Return a Django form suitable for populating the context data required to run this Script.
  258. """
  259. # Create a dynamic ScriptForm subclass from script variables
  260. fields = {
  261. name: var.as_field() for name, var in self._get_vars().items()
  262. }
  263. FormClass = type('ScriptForm', (ScriptForm,), fields)
  264. form = FormClass(data, files, initial=initial)
  265. # Set initial "commit" checkbox state based on the script's Meta parameter
  266. form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
  267. return form
  268. # Logging
  269. def log_debug(self, message):
  270. self.logger.log(logging.DEBUG, message)
  271. self.log.append((LogLevelChoices.LOG_DEFAULT, message))
  272. def log_success(self, message):
  273. self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS
  274. self.log.append((LogLevelChoices.LOG_SUCCESS, message))
  275. def log_info(self, message):
  276. self.logger.log(logging.INFO, message)
  277. self.log.append((LogLevelChoices.LOG_INFO, message))
  278. def log_warning(self, message):
  279. self.logger.log(logging.WARNING, message)
  280. self.log.append((LogLevelChoices.LOG_WARNING, message))
  281. def log_failure(self, message):
  282. self.logger.log(logging.ERROR, message)
  283. self.log.append((LogLevelChoices.LOG_FAILURE, message))
  284. # Convenience functions
  285. def load_yaml(self, filename):
  286. """
  287. Return data from a YAML file
  288. """
  289. file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
  290. with open(file_path, 'r') as datafile:
  291. data = yaml.load(datafile)
  292. return data
  293. def load_json(self, filename):
  294. """
  295. Return data from a JSON file
  296. """
  297. file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
  298. with open(file_path, 'r') as datafile:
  299. data = json.load(datafile)
  300. return data
  301. class Script(BaseScript):
  302. """
  303. Classes which inherit this model will appear in the list of available scripts.
  304. """
  305. pass
  306. #
  307. # Functions
  308. #
  309. def is_script(obj):
  310. """
  311. Returns True if the object is a Script.
  312. """
  313. try:
  314. return issubclass(obj, Script) and obj != Script
  315. except TypeError:
  316. return False
  317. def is_variable(obj):
  318. """
  319. Returns True if the object is a ScriptVariable.
  320. """
  321. return isinstance(obj, ScriptVariable)
  322. @job('default')
  323. def run_script(data, request, commit=True, *args, **kwargs):
  324. """
  325. A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
  326. exists outside of the Script class to ensure it cannot be overridden by a script author.
  327. """
  328. job_result = kwargs.pop('job_result')
  329. module, script_name = job_result.name.split('.', 1)
  330. script = get_script(module, script_name)()
  331. job_result.status = JobResultStatusChoices.STATUS_RUNNING
  332. job_result.save()
  333. logger = logging.getLogger(f"netbox.scripts.{module}.{script_name}")
  334. logger.info(f"Running script (commit={commit})")
  335. # Add files to form data
  336. files = request.FILES
  337. for field_name, fileobj in files.items():
  338. data[field_name] = fileobj
  339. # Add the current request as a property of the script
  340. script.request = request
  341. def _run_script():
  342. """
  343. Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
  344. the change_logging context manager (which is bypassed if commit == False).
  345. """
  346. try:
  347. with transaction.atomic():
  348. script.output = script.run(data=data, commit=commit)
  349. job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
  350. if not commit:
  351. raise AbortTransaction()
  352. except AbortTransaction:
  353. script.log_info("Database changes have been reverted automatically.")
  354. except Exception as e:
  355. stacktrace = traceback.format_exc()
  356. script.log_failure(
  357. f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
  358. )
  359. script.log_info("Database changes have been reverted due to error.")
  360. logger.error(f"Exception raised during script execution: {e}")
  361. job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
  362. finally:
  363. job_result.data = ScriptOutputSerializer(script).data
  364. job_result.save()
  365. logger.info(f"Script completed in {job_result.duration}")
  366. # Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
  367. # change logging, webhooks, etc.
  368. if commit:
  369. with change_logging(request):
  370. _run_script()
  371. else:
  372. _run_script()
  373. # Delete any previous terminal state results
  374. JobResult.objects.filter(
  375. obj_type=job_result.obj_type,
  376. name=job_result.name,
  377. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  378. ).exclude(
  379. pk=job_result.pk
  380. ).delete()
  381. def get_scripts(use_names=False):
  382. """
  383. Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human-
  384. defined name in place of the actual module name.
  385. """
  386. scripts = OrderedDict()
  387. # Iterate through all modules within the reports path. These are the user-created files in which reports are
  388. # defined.
  389. for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
  390. module = importer.find_module(module_name).load_module(module_name)
  391. if use_names and hasattr(module, 'name'):
  392. module_name = module.name
  393. module_scripts = OrderedDict()
  394. for name, cls in inspect.getmembers(module, is_script):
  395. module_scripts[name] = cls
  396. if module_scripts:
  397. scripts[module_name] = module_scripts
  398. return scripts
  399. def get_script(module_name, script_name):
  400. """
  401. Retrieve a script class by module and name. Returns None if the script does not exist.
  402. """
  403. scripts = get_scripts()
  404. module = scripts.get(module_name)
  405. if module:
  406. return module.get(script_name)