1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

486 lines
14 KiB
Python
Raw Normal View History

2019-08-09 12:33:33 -04:00
import inspect
import json
import logging
import os
2019-08-09 12:33:33 -04:00
import pkgutil
import traceback
2019-10-04 12:08:48 -04:00
from collections import OrderedDict
2019-08-09 12:33:33 -04:00
2019-10-04 12:08:48 -04:00
import yaml
2019-08-09 12:33:33 -04:00
from django import forms
from django.conf import settings
from django.core.validators import RegexValidator
2019-08-12 13:51:25 -04:00
from django.db import transaction
from django.utils.decorators import classproperty
from django_rq import job
2019-08-09 12:33:33 -04:00
from extras.api.serializers import ScriptOutputSerializer
2020-07-06 01:58:28 -04:00
from extras.choices import JobResultStatusChoices, LogLevelChoices
from extras.models import JobResult
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
2019-08-09 12:33:33 -04:00
from .forms import ScriptForm
from .signals import purge_changelog
2019-08-09 12:33:33 -04:00
2019-08-09 16:34:01 -04:00
__all__ = [
'BaseScript',
2019-08-16 15:27:58 -04:00
'BooleanVar',
'ChoiceVar',
2019-08-16 15:27:58 -04:00
'FileVar',
'IntegerVar',
'IPAddressVar',
'IPAddressWithMaskVar',
2019-08-16 15:27:58 -04:00
'IPNetworkVar',
'MultiObjectVar',
2019-08-16 15:27:58 -04:00
'ObjectVar',
2019-08-09 16:34:01 -04:00
'Script',
'StringVar',
2019-08-14 16:20:52 -04:00
'TextVar',
2019-08-09 16:34:01 -04:00
]
2019-08-09 12:33:33 -04:00
#
# Script variables
#
class ScriptVariable:
"""
Base model for script variables
"""
2019-08-09 12:33:33 -04:00
form_field = forms.CharField
def __init__(self, label='', description='', default=None, required=True, widget=None):
2019-08-09 12:33:33 -04:00
# Initialize field attributes
if not hasattr(self, 'field_attrs'):
self.field_attrs = {}
2019-08-09 12:33:33 -04:00
if label:
self.field_attrs['label'] = label
if description:
self.field_attrs['help_text'] = description
if default:
self.field_attrs['initial'] = default
if widget:
self.field_attrs['widget'] = widget
self.field_attrs['required'] = required
2019-08-09 12:33:33 -04:00
def as_field(self):
"""
Render the variable as a Django form field.
"""
2019-08-14 09:40:23 -04:00
form_field = self.form_field(**self.field_attrs)
if not isinstance(form_field.widget, forms.CheckboxInput):
2020-02-11 20:27:02 -06:00
if form_field.widget.attrs and 'class' in form_field.widget.attrs.keys():
form_field.widget.attrs['class'] += ' form-control'
else:
form_field.widget.attrs['class'] = 'form-control'
2019-08-14 09:40:23 -04:00
return form_field
2019-08-09 12:33:33 -04:00
class StringVar(ScriptVariable):
"""
Character string representation. Can enforce minimum/maximum length and/or regex validation.
"""
def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs):
super().__init__(*args, **kwargs)
# Optional minimum/maximum lengths
if min_length:
self.field_attrs['min_length'] = min_length
if max_length:
self.field_attrs['max_length'] = max_length
# Optional regular expression validation
if regex:
self.field_attrs['validators'] = [
RegexValidator(
regex=regex,
message='Invalid value. Must match regex: {}'.format(regex),
code='invalid'
)
]
2019-08-09 12:33:33 -04:00
2019-08-14 16:20:52 -04:00
class TextVar(ScriptVariable):
"""
Free-form text data. Renders as a <textarea>.
"""
form_field = forms.CharField
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.field_attrs['widget'] = forms.Textarea
2019-08-09 12:33:33 -04:00
class IntegerVar(ScriptVariable):
"""
Integer representation. Can enforce minimum/maximum values.
"""
2019-08-09 12:33:33 -04:00
form_field = forms.IntegerField
def __init__(self, min_value=None, max_value=None, *args, **kwargs):
super().__init__(*args, **kwargs)
# Optional minimum/maximum values
if min_value:
self.field_attrs['min_value'] = min_value
if max_value:
self.field_attrs['max_value'] = max_value
2019-08-09 12:33:33 -04:00
class BooleanVar(ScriptVariable):
"""
Boolean representation (true/false). Renders as a checkbox.
"""
2019-08-09 16:45:00 -04:00
form_field = forms.BooleanField
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Boolean fields cannot be required
self.field_attrs['required'] = False
2019-08-09 12:33:33 -04:00
class ChoiceVar(ScriptVariable):
"""
Select one of several predefined static choices, passed as a list of two-tuples. Example:
color = ChoiceVar(
choices=(
('#ff0000', 'Red'),
('#00ff00', 'Green'),
('#0000ff', 'Blue')
)
)
"""
form_field = forms.ChoiceField
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set field choices
self.field_attrs['choices'] = choices
2019-08-09 12:33:33 -04:00
class ObjectVar(ScriptVariable):
"""
A single object within NetBox.
"""
form_field = DynamicModelChoiceField
2019-08-09 12:33:33 -04:00
def __init__(self, queryset, *args, **kwargs):
super().__init__(*args, **kwargs)
# Queryset for field choices
2019-08-09 12:33:33 -04:00
self.field_attrs['queryset'] = queryset
class MultiObjectVar(ScriptVariable):
"""
Like ObjectVar, but can represent one or more objects.
"""
form_field = DynamicModelMultipleChoiceField
def __init__(self, queryset, *args, **kwargs):
super().__init__(*args, **kwargs)
# Queryset for field choices
self.field_attrs['queryset'] = queryset
2019-08-16 15:27:58 -04:00
class FileVar(ScriptVariable):
"""
An uploaded file.
"""
form_field = forms.FileField
class IPAddressVar(ScriptVariable):
"""
An IPv4 or IPv6 address without a mask.
"""
form_field = IPAddressFormField
class IPAddressWithMaskVar(ScriptVariable):
"""
An IPv4 or IPv6 address with a mask.
"""
form_field = IPNetworkFormField
2019-08-13 09:48:51 -04:00
class IPNetworkVar(ScriptVariable):
"""
An IPv4 or IPv6 prefix.
"""
form_field = IPNetworkFormField
2019-08-13 09:48:51 -04:00
def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set prefix validator and optional minimum/maximum prefix lengths
self.field_attrs['validators'] = [prefix_validator]
if min_prefix_length is not None:
self.field_attrs['validators'].append(
MinPrefixLengthValidator(min_prefix_length)
)
if max_prefix_length is not None:
self.field_attrs['validators'].append(
MaxPrefixLengthValidator(max_prefix_length)
)
2019-08-13 09:48:51 -04:00
#
# Scripts
#
class BaseScript:
2019-08-09 12:33:33 -04:00
"""
Base model for custom scripts. User classes should inherit from this model if they want to extend Script
functionality for use in other subclasses.
2019-08-09 12:33:33 -04:00
"""
class Meta:
pass
2019-08-09 12:33:33 -04:00
def __init__(self):
# Initiate the log
self.logger = logging.getLogger(f"netbox.scripts.{self.module()}.{self.__class__.__name__}")
2019-08-09 12:33:33 -04:00
self.log = []
# Declare the placeholder for the current request
self.request = None
2019-08-09 12:33:33 -04:00
# Grab some info about the script
self.filename = inspect.getfile(self.__class__)
self.source = inspect.getsource(self.__class__)
def __str__(self):
2020-06-29 14:34:42 -04:00
return self.name
@classproperty
def name(self):
return getattr(self.Meta, 'name', self.__class__.__name__)
2019-08-09 12:33:33 -04:00
@classproperty
def full_name(self):
return '.'.join([self.__module__, self.__name__])
2020-06-29 14:34:42 -04:00
@classproperty
def description(self):
return getattr(self.Meta, 'description', '')
@classmethod
def module(cls):
return cls.__module__
@classmethod
def _get_vars(cls):
2019-08-09 16:34:01 -04:00
vars = OrderedDict()
for name, attr in cls.__dict__.items():
2019-08-09 16:34:01 -04:00
if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr
return vars
2019-08-09 12:33:33 -04:00
def run(self, data, commit):
2019-08-09 12:33:33 -04:00
raise NotImplementedError("The script must define a run() method.")
def as_form(self, data=None, files=None, initial=None):
2019-08-09 12:33:33 -04:00
"""
Return a Django form suitable for populating the context data required to run this Script.
"""
# Create a dynamic ScriptForm subclass from script variables
fields = {
name: var.as_field() for name, var in self._get_vars().items()
}
FormClass = type('ScriptForm', (ScriptForm,), fields)
form = FormClass(data, files, initial=initial)
# Set initial "commit" checkbox state based on the script's Meta parameter
form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
2019-08-09 12:33:33 -04:00
return form
# Logging
def log_debug(self, message):
self.logger.log(logging.DEBUG, message)
2020-07-06 01:58:28 -04:00
self.log.append((LogLevelChoices.LOG_DEFAULT, message))
2019-08-09 12:33:33 -04:00
def log_success(self, message):
self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS
2020-07-06 01:58:28 -04:00
self.log.append((LogLevelChoices.LOG_SUCCESS, message))
2019-08-09 12:33:33 -04:00
def log_info(self, message):
self.logger.log(logging.INFO, message)
2020-07-06 01:58:28 -04:00
self.log.append((LogLevelChoices.LOG_INFO, message))
2019-08-09 12:33:33 -04:00
def log_warning(self, message):
self.logger.log(logging.WARNING, message)
2020-07-06 01:58:28 -04:00
self.log.append((LogLevelChoices.LOG_WARNING, message))
2019-08-09 12:33:33 -04:00
def log_failure(self, message):
self.logger.log(logging.ERROR, message)
2020-07-06 01:58:28 -04:00
self.log.append((LogLevelChoices.LOG_FAILURE, message))
2019-08-09 12:33:33 -04:00
# Convenience functions
def load_yaml(self, filename):
"""
Return data from a YAML file
"""
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = yaml.load(datafile)
return data
def load_json(self, filename):
"""
Return data from a JSON file
"""
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = json.load(datafile)
return data
2019-08-09 12:33:33 -04:00
class Script(BaseScript):
"""
Classes which inherit this model will appear in the list of available scripts.
"""
pass
2019-08-09 12:33:33 -04:00
#
# Functions
#
def is_script(obj):
"""
Returns True if the object is a Script.
"""
try:
return issubclass(obj, Script) and obj != Script
except TypeError:
return False
2019-08-09 12:33:33 -04:00
def is_variable(obj):
"""
Returns True if the object is a ScriptVariable.
"""
return isinstance(obj, ScriptVariable)
@job('default')
def run_script(data, request, commit=True, *args, **kwargs):
2019-08-12 13:51:25 -04:00
"""
2019-08-12 14:28:06 -04:00
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
exists outside of the Script class to ensure it cannot be overridden by a script author.
2019-08-12 13:51:25 -04:00
"""
job_result = kwargs.pop('job_result')
module, script_name = job_result.name.split('.', 1)
script = get_script(module, script_name)()
job_result.status = JobResultStatusChoices.STATUS_RUNNING
job_result.save()
logger = logging.getLogger(f"netbox.scripts.{module}.{script_name}")
logger.info(f"Running script (commit={commit})")
2019-08-16 15:27:58 -04:00
# Add files to form data
files = request.FILES
2019-08-16 15:27:58 -04:00
for field_name, fileobj in files.items():
data[field_name] = fileobj
# Add the current request as a property of the script
script.request = request
# Determine whether the script accepts a 'commit' argument (this was introduced in v2.7.8)
kwargs = {
'data': data
}
if 'commit' in inspect.signature(script.run).parameters:
kwargs['commit'] = commit
2019-08-12 13:51:25 -04:00
try:
with transaction.atomic():
script.output = script.run(**kwargs)
2020-07-06 01:58:28 -04:00
job_result.data = ScriptOutputSerializer(script).data
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
2019-08-12 14:28:06 -04:00
if not commit:
raise AbortTransaction()
2020-07-06 01:58:28 -04:00
2019-08-12 14:28:06 -04:00
except AbortTransaction:
pass
2020-07-06 01:58:28 -04:00
2019-08-12 13:51:25 -04:00
except Exception as e:
stacktrace = traceback.format_exc()
2019-08-12 13:51:25 -04:00
script.log_failure(
2019-08-14 14:38:11 -04:00
"An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
2019-08-12 13:51:25 -04:00
)
logger.error(f"Exception raised during script execution: {e}")
2019-08-12 14:28:06 -04:00
commit = False
2020-07-06 01:58:28 -04:00
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
2019-08-12 14:28:06 -04:00
finally:
if not commit:
# Delete all pending changelog entries
purge_changelog.send(Script)
2019-08-12 14:28:06 -04:00
script.log_info(
"Database changes have been reverted automatically."
)
logger.info(f"Script completed in {job_result.duration}")
2019-08-14 10:12:30 -04:00
# Delete any previous terminal state results
JobResult.objects.filter(
obj_type=job_result.obj_type,
2020-06-30 09:29:50 -04:00
name=job_result.name,
2020-07-03 11:55:04 -04:00
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).exclude(
pk=job_result.pk
).delete()
2019-08-12 13:51:25 -04:00
def get_scripts(use_names=False):
"""
Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human-
defined name in place of the actual module name.
"""
2019-08-09 12:33:33 -04:00
scripts = OrderedDict()
# Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
module = importer.find_module(module_name).load_module(module_name)
if use_names and hasattr(module, 'name'):
2019-08-13 09:09:12 -04:00
module_name = module.name
2019-08-09 12:33:33 -04:00
module_scripts = OrderedDict()
for name, cls in inspect.getmembers(module, is_script):
module_scripts[name] = cls
if module_scripts:
scripts[module_name] = module_scripts
2019-08-09 12:33:33 -04:00
return scripts
def get_script(module_name, script_name):
"""
Retrieve a script class by module and name. Returns None if the script does not exist.
"""
scripts = get_scripts()
module = scripts.get(module_name)
if module:
return module.get(script_name)