scripts.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  1. import inspect
  2. import json
  3. import logging
  4. import os
  5. import re
  6. import yaml
  7. from django import forms
  8. from django.conf import settings
  9. from django.core.files.storage import storages
  10. from django.core.validators import RegexValidator
  11. from django.utils import timezone
  12. from django.utils.functional import classproperty
  13. from django.utils.translation import gettext as _
  14. from extras.choices import LogLevelChoices
  15. from extras.models import ScriptModule
  16. from ipam.formfields import IPAddressFormField, IPNetworkFormField
  17. from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
  18. from utilities.forms import add_blank_choice
  19. from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
  20. from utilities.forms.widgets import DatePicker, DateTimePicker
  21. from .forms import ScriptForm
  22. __all__ = (
  23. 'BaseScript',
  24. 'BooleanVar',
  25. 'ChoiceVar',
  26. 'DateVar',
  27. 'DateTimeVar',
  28. 'FileVar',
  29. 'IntegerVar',
  30. 'IPAddressVar',
  31. 'IPAddressWithMaskVar',
  32. 'IPNetworkVar',
  33. 'MultiChoiceVar',
  34. 'MultiObjectVar',
  35. 'ObjectVar',
  36. 'Script',
  37. 'StringVar',
  38. 'TextVar',
  39. 'get_module_and_script',
  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 DateVar(ScriptVariable):
  139. """
  140. A date.
  141. """
  142. form_field = forms.DateField
  143. def __init__(self, *args, **kwargs):
  144. super().__init__(*args, **kwargs)
  145. self.form_field.widget = DatePicker()
  146. class DateTimeVar(ScriptVariable):
  147. """
  148. A date and a time.
  149. """
  150. form_field = forms.DateTimeField
  151. def __init__(self, *args, **kwargs):
  152. super().__init__(*args, **kwargs)
  153. self.form_field.widget = DateTimePicker()
  154. class MultiChoiceVar(ScriptVariable):
  155. """
  156. Like ChoiceVar, but allows for the selection of multiple choices.
  157. """
  158. form_field = forms.MultipleChoiceField
  159. def __init__(self, choices, *args, **kwargs):
  160. super().__init__(*args, **kwargs)
  161. # Set field choices
  162. self.field_attrs['choices'] = choices
  163. class ObjectVar(ScriptVariable):
  164. """
  165. A single object within NetBox.
  166. :param model: The NetBox model being referenced
  167. :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
  168. :param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
  169. elements within the dropdown menu (optional)
  170. :param null_option: The label to use as a "null" selection option (optional)
  171. :param selector: Include an advanced object selection widget to assist the user in identifying the desired
  172. object (optional)
  173. """
  174. form_field = DynamicModelChoiceField
  175. def __init__(self, model, query_params=None, context=None, null_option=None, selector=False, *args, **kwargs):
  176. super().__init__(*args, **kwargs)
  177. self.field_attrs.update({
  178. 'queryset': model.objects.all(),
  179. 'query_params': query_params,
  180. 'context': context,
  181. 'null_option': null_option,
  182. 'selector': selector,
  183. })
  184. class MultiObjectVar(ObjectVar):
  185. """
  186. Like ObjectVar, but can represent one or more objects.
  187. """
  188. form_field = DynamicModelMultipleChoiceField
  189. class FileVar(ScriptVariable):
  190. """
  191. An uploaded file.
  192. """
  193. form_field = forms.FileField
  194. class IPAddressVar(ScriptVariable):
  195. """
  196. An IPv4 or IPv6 address without a mask.
  197. """
  198. form_field = IPAddressFormField
  199. class IPAddressWithMaskVar(ScriptVariable):
  200. """
  201. An IPv4 or IPv6 address with a mask.
  202. """
  203. form_field = IPNetworkFormField
  204. class IPNetworkVar(ScriptVariable):
  205. """
  206. An IPv4 or IPv6 prefix.
  207. """
  208. form_field = IPNetworkFormField
  209. def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
  210. super().__init__(*args, **kwargs)
  211. # Set prefix validator and optional minimum/maximum prefix lengths
  212. self.field_attrs['validators'] = [prefix_validator]
  213. if min_prefix_length is not None:
  214. self.field_attrs['validators'].append(
  215. MinPrefixLengthValidator(min_prefix_length)
  216. )
  217. if max_prefix_length is not None:
  218. self.field_attrs['validators'].append(
  219. MaxPrefixLengthValidator(max_prefix_length)
  220. )
  221. #
  222. # Scripts
  223. #
  224. class BaseScript:
  225. """
  226. Base model for custom scripts. User classes should inherit from this model if they want to extend Script
  227. functionality for use in other subclasses.
  228. """
  229. # Prevent django from instantiating the class on all accesses
  230. do_not_call_in_templates = True
  231. class Meta:
  232. pass
  233. def __init__(self):
  234. self.messages = [] # Primary script log
  235. self.tests = {} # Mapping of logs for test methods
  236. self.output = ''
  237. self.failed = False
  238. self._current_test = None # Tracks the current test method being run (if any)
  239. # Initiate the log
  240. self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}")
  241. # Declare the placeholder for the current request
  242. self.request = None
  243. # Compile test methods and initialize results skeleton
  244. for method in dir(self):
  245. if method.startswith('test_') and callable(getattr(self, method)):
  246. self.tests[method] = {
  247. LogLevelChoices.LOG_SUCCESS: 0,
  248. LogLevelChoices.LOG_INFO: 0,
  249. LogLevelChoices.LOG_WARNING: 0,
  250. LogLevelChoices.LOG_FAILURE: 0,
  251. 'log': [],
  252. }
  253. def __str__(self):
  254. return self.name
  255. @classproperty
  256. def module(self):
  257. return self.__module__
  258. @classproperty
  259. def class_name(self):
  260. return self.__name__
  261. @classproperty
  262. def full_name(self):
  263. return f'{self.module}.{self.class_name}'
  264. @classmethod
  265. def root_module(cls):
  266. return cls.__module__.split(".")[0]
  267. # Author-defined attributes
  268. @classproperty
  269. def name(self):
  270. return getattr(self.Meta, 'name', self.__name__)
  271. @classproperty
  272. def description(self):
  273. return getattr(self.Meta, 'description', '')
  274. @classproperty
  275. def field_order(self):
  276. return getattr(self.Meta, 'field_order', None)
  277. @classproperty
  278. def fieldsets(self):
  279. return getattr(self.Meta, 'fieldsets', None)
  280. @classproperty
  281. def commit_default(self):
  282. return getattr(self.Meta, 'commit_default', True)
  283. @classproperty
  284. def job_timeout(self):
  285. return getattr(self.Meta, 'job_timeout', None)
  286. @classproperty
  287. def scheduling_enabled(self):
  288. return getattr(self.Meta, 'scheduling_enabled', True)
  289. @property
  290. def filename(self):
  291. return inspect.getfile(self.__class__)
  292. def findsource(self, object):
  293. storage = storages.create_storage(storages.backends["scripts"])
  294. with storage.open(os.path.basename(self.filename), 'r') as f:
  295. data = f.read()
  296. # Break the source code into lines
  297. lines = [line + '\n' for line in data.splitlines()]
  298. # Find the class definition
  299. name = object.__name__
  300. pat = re.compile(r'^(\s*)class\s*' + name + r'\b')
  301. # use the class definition with the least indentation
  302. candidates = []
  303. for i in range(len(lines)):
  304. match = pat.match(lines[i])
  305. if match:
  306. if lines[i][0] == 'c':
  307. return lines, i
  308. candidates.append((match.group(1), i))
  309. if not candidates:
  310. raise OSError('could not find class definition')
  311. # Sort the candidates by whitespace, and by line number
  312. candidates.sort()
  313. return lines, candidates[0][1]
  314. @property
  315. def source(self):
  316. # Can't use inspect.getsource() as it uses os to get the file
  317. # inspect uses ast, but that is overkill for this as we only do
  318. # classes.
  319. object = self.__class__
  320. try:
  321. lines, lnum = self.findsource(object)
  322. lines = inspect.getblock(lines[lnum:])
  323. return ''.join(lines)
  324. except OSError:
  325. return ''
  326. @classmethod
  327. def _get_vars(cls):
  328. vars = {}
  329. # Iterate all base classes looking for ScriptVariables
  330. for base_class in inspect.getmro(cls):
  331. # When object is reached there's no reason to continue
  332. if base_class is object:
  333. break
  334. for name, attr in base_class.__dict__.items():
  335. if name not in vars and issubclass(attr.__class__, ScriptVariable):
  336. vars[name] = attr
  337. # Order variables according to field_order
  338. if not cls.field_order:
  339. return vars
  340. ordered_vars = {
  341. field: vars.pop(field) for field in cls.field_order if field in vars
  342. }
  343. ordered_vars.update(vars)
  344. return ordered_vars
  345. def run(self, data, commit):
  346. """
  347. Override this method with custom script logic.
  348. """
  349. # Backward compatibility for legacy Reports
  350. self.pre_run()
  351. self.run_tests()
  352. self.post_run()
  353. def get_job_data(self):
  354. """
  355. Return a dictionary of data to attach to the script's Job.
  356. """
  357. return {
  358. 'log': self.messages,
  359. 'output': self.output,
  360. 'tests': self.tests,
  361. }
  362. #
  363. # Form rendering
  364. #
  365. def get_fieldsets(self):
  366. fieldsets = []
  367. if self.fieldsets:
  368. fieldsets.extend(self.fieldsets)
  369. else:
  370. fields = list(name for name, _ in self._get_vars().items())
  371. fieldsets.append((_('Script Data'), fields))
  372. # Append the default fieldset if defined in the Meta class
  373. exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
  374. fieldsets.append((_('Script Execution Parameters'), exec_parameters))
  375. return fieldsets
  376. def as_form(self, data=None, files=None, initial=None):
  377. """
  378. Return a Django form suitable for populating the context data required to run this Script.
  379. """
  380. # Create a dynamic ScriptForm subclass from script variables
  381. fields = {
  382. name: var.as_field() for name, var in self._get_vars().items()
  383. }
  384. FormClass = type('ScriptForm', (ScriptForm,), fields)
  385. form = FormClass(data, files, initial=initial)
  386. # Set initial "commit" checkbox state based on the script's Meta parameter
  387. form.fields['_commit'].initial = self.commit_default
  388. # Hide fields if scheduling has been disabled
  389. if not self.scheduling_enabled:
  390. form.fields['_schedule_at'].widget = forms.HiddenInput()
  391. form.fields['_interval'].widget = forms.HiddenInput()
  392. return form
  393. #
  394. # Logging
  395. #
  396. def _log(self, message, obj=None, level=LogLevelChoices.LOG_INFO):
  397. """
  398. Log a message. Do not call this method directly; use one of the log_* wrappers below.
  399. """
  400. if level not in LogLevelChoices.values():
  401. raise ValueError(f"Invalid logging level: {level}")
  402. # A test method is currently active, so log the message using legacy Report logging
  403. if self._current_test:
  404. # Increment the event counter for this level
  405. if level in self.tests[self._current_test]:
  406. self.tests[self._current_test][level] += 1
  407. # Record message (if any) to the report log
  408. if message:
  409. # TODO: Use a dataclass for test method logs
  410. self.tests[self._current_test]['log'].append((
  411. timezone.now().isoformat(),
  412. level,
  413. str(obj) if obj else None,
  414. obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
  415. str(message),
  416. ))
  417. elif message:
  418. # Record to the script's log
  419. self.messages.append({
  420. 'time': timezone.now().isoformat(),
  421. 'status': level,
  422. 'message': str(message),
  423. 'obj': str(obj) if obj else None,
  424. 'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
  425. })
  426. # Record to the system log
  427. if obj:
  428. message = f"{obj}: {message}"
  429. self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
  430. def log_debug(self, message=None, obj=None):
  431. self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
  432. def log_success(self, message=None, obj=None):
  433. self._log(message, obj, level=LogLevelChoices.LOG_SUCCESS)
  434. def log_info(self, message=None, obj=None):
  435. self._log(message, obj, level=LogLevelChoices.LOG_INFO)
  436. def log_warning(self, message=None, obj=None):
  437. self._log(message, obj, level=LogLevelChoices.LOG_WARNING)
  438. def log_failure(self, message=None, obj=None):
  439. self._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
  440. self.failed = True
  441. #
  442. # Convenience functions
  443. #
  444. def load_yaml(self, filename):
  445. """
  446. Return data from a YAML file
  447. """
  448. # TODO: DEPRECATED: Remove this method in v4.4
  449. self._log(
  450. _("load_yaml is deprecated and will be removed in v4.4"),
  451. level=LogLevelChoices.LOG_WARNING
  452. )
  453. file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
  454. with open(file_path, 'r') as datafile:
  455. data = yaml.load(datafile, Loader=yaml.SafeLoader)
  456. return data
  457. def load_json(self, filename):
  458. """
  459. Return data from a JSON file
  460. """
  461. # TODO: DEPRECATED: Remove this method in v4.4
  462. self._log(
  463. _("load_json is deprecated and will be removed in v4.4"),
  464. level=LogLevelChoices.LOG_WARNING
  465. )
  466. file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
  467. with open(file_path, 'r') as datafile:
  468. data = json.load(datafile)
  469. return data
  470. #
  471. # Legacy Report functionality
  472. #
  473. def run_tests(self):
  474. """
  475. Run the report and save its results. Each test method will be executed in order.
  476. """
  477. self.logger.info("Running report")
  478. try:
  479. for test_name in self.tests:
  480. self._current_test = test_name
  481. test_method = getattr(self, test_name)
  482. test_method()
  483. self._current_test = None
  484. except Exception as e:
  485. self._current_test = None
  486. self.post_run()
  487. raise e
  488. def pre_run(self):
  489. """
  490. Legacy method for operations performed immediately prior to running a Report.
  491. """
  492. pass
  493. def post_run(self):
  494. """
  495. Legacy method for operations performed immediately after running a Report.
  496. """
  497. pass
  498. class Script(BaseScript):
  499. """
  500. Classes which inherit this model will appear in the list of available scripts.
  501. """
  502. pass
  503. #
  504. # Functions
  505. #
  506. def is_variable(obj):
  507. """
  508. Returns True if the object is a ScriptVariable.
  509. """
  510. return isinstance(obj, ScriptVariable)
  511. def get_module_and_script(module_name, script_name):
  512. module = ScriptModule.objects.get(file_path=f'{module_name}.py')
  513. script = module.scripts.get(name=script_name)
  514. return module, script