scripts.py 14 KB

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