mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
Merge pull request #3423 from netbox-community/3415-custom-scripts
Add custom scripting
This commit is contained in:
commit
03ac2721bc
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,6 +3,8 @@
|
||||
/netbox/netbox/ldap_config.py
|
||||
/netbox/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/scripts/*
|
||||
!/netbox/scripts/__init__.py
|
||||
/netbox/static
|
||||
.idea
|
||||
/*.sh
|
||||
|
213
docs/additional-features/custom-scripts.md
Normal file
213
docs/additional-features/custom-scripts.md
Normal file
@ -0,0 +1,213 @@
|
||||
# Custom Scripts
|
||||
|
||||
Custom scripting was introduced in NetBox v2.7 to provide a way for users to execute custom logic from within the NetBox UI. Custom scripts enable the user to directly and conveniently manipulate NetBox data in a prescribed fashion. They can be used to accomplish myriad tasks, such as:
|
||||
|
||||
* Automatically populate new devices and cables in preparation for a new site deployment
|
||||
* Create a range of new reserved prefixes or IP addresses
|
||||
* Fetch data from an external source and import it to NetBox
|
||||
|
||||
Custom scripts are Python code and exist outside of the official NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're written from scratch, a custom script can be used to accomplish just about anything.
|
||||
|
||||
## Writing Custom Scripts
|
||||
|
||||
All custom scripts must inherit from the `extras.scripts.Script` base class. This class provides the functionality necessary to generate forms and log activity.
|
||||
|
||||
```
|
||||
from extras.scripts import Script
|
||||
|
||||
class MyScript(Script):
|
||||
..
|
||||
```
|
||||
|
||||
Scripts comprise two core components: variables and a `run()` method. Variables allow your script to accept user input via the NetBox UI. The `run()` method is where your script's execution logic lives. (Note that your script can have as many methods as needed: this is merely the point of invocation for NetBox.)
|
||||
|
||||
```
|
||||
class MyScript(Script):
|
||||
var1 = StringVar(...)
|
||||
var2 = IntegerVar(...)
|
||||
var3 = ObjectVar(...)
|
||||
|
||||
def run(self, data):
|
||||
...
|
||||
```
|
||||
|
||||
The `run()` method is passed a single argument: a dictionary containing all of the variable data passed via the web form. Your script can reference this data during execution.
|
||||
|
||||
Defining variables is optional: You may create a script with only a `run()` method if no user input is needed.
|
||||
|
||||
Returning output from your script is optional. Any raw output generated by the script will be displayed under the "output" tab in the UI.
|
||||
|
||||
## Module Attributes
|
||||
|
||||
### `name`
|
||||
|
||||
You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the filename will be used.
|
||||
|
||||
## Script Attributes
|
||||
|
||||
Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
|
||||
|
||||
### `name`
|
||||
|
||||
This is the human-friendly names of your script. If omitted, the class name will be used.
|
||||
|
||||
### `description`
|
||||
|
||||
A human-friendly description of what your script does.
|
||||
|
||||
### `field_order`
|
||||
|
||||
A list of field names indicating the order in which the form fields should appear. This is optional, however on Python 3.5 and earlier the fields will appear in random order. (Declarative ordering is preserved on Python 3.6 and above.) For example:
|
||||
|
||||
```
|
||||
field_order = ['var1', 'var2', 'var3']
|
||||
```
|
||||
|
||||
## Reading Data from Files
|
||||
|
||||
The Script class provides two convenience methods for reading data from files:
|
||||
|
||||
* `load_yaml`
|
||||
* `load_json`
|
||||
|
||||
These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`).
|
||||
|
||||
## Logging
|
||||
|
||||
The Script object provides a set of convenient functions for recording messages at different severity levels:
|
||||
|
||||
* `log_debug`
|
||||
* `log_success`
|
||||
* `log_info`
|
||||
* `log_warning`
|
||||
* `log_failure`
|
||||
|
||||
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages.
|
||||
|
||||
## Variable Reference
|
||||
|
||||
### StringVar
|
||||
|
||||
Stores a string of characters (i.e. a line of text). Options include:
|
||||
|
||||
* `min_length` - Minimum number of characters
|
||||
* `max_length` - Maximum number of characters
|
||||
* `regex` - A regular expression against which the provided value must match
|
||||
|
||||
Note: `min_length` and `max_length` can be set to the same number to effect a fixed-length field.
|
||||
|
||||
### TextVar
|
||||
|
||||
Arbitrary text of any length. Renders as multi-line text input field.
|
||||
|
||||
### IntegerVar
|
||||
|
||||
Stored a numeric integer. Options include:
|
||||
|
||||
* `min_value:` - Minimum value
|
||||
* `max_value` - Maximum value
|
||||
|
||||
### BooleanVar
|
||||
|
||||
A true/false flag. This field has no options beyond the defaults.
|
||||
|
||||
### ObjectVar
|
||||
|
||||
A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type.
|
||||
|
||||
* `queryset` - A [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/)
|
||||
|
||||
### FileVar
|
||||
|
||||
An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use.
|
||||
|
||||
### IPNetworkVar
|
||||
|
||||
An IPv4 or IPv6 network with a mask.
|
||||
|
||||
### Default Options
|
||||
|
||||
All variables support the following default options:
|
||||
|
||||
* `label` - The name of the form field
|
||||
* `description` - A brief description of the field
|
||||
* `default` - The field's default value
|
||||
* `required` - Indicates whether the field is mandatory (default: true)
|
||||
|
||||
## Example
|
||||
|
||||
Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
|
||||
|
||||
* The name of the new site
|
||||
* The device model (a filtered list of defined device types)
|
||||
* The number of access switches to create
|
||||
|
||||
These variables are presented as a web form to be completed by the user. Once submitted, the script's `run()` method is called to create the appropriate objects.
|
||||
|
||||
```
|
||||
from django.utils.text import slugify
|
||||
|
||||
from dcim.constants import *
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Site
|
||||
from extras.scripts import *
|
||||
|
||||
|
||||
class NewBranchScript(Script):
|
||||
|
||||
class Meta:
|
||||
name = "New Branch"
|
||||
description = "Provision a new branch site"
|
||||
fields = ['site_name', 'switch_count', 'switch_model']
|
||||
|
||||
site_name = StringVar(
|
||||
description="Name of the new site"
|
||||
)
|
||||
switch_count = IntegerVar(
|
||||
description="Number of access switches to create"
|
||||
)
|
||||
switch_model = ObjectVar(
|
||||
description="Access switch model",
|
||||
queryset = DeviceType.objects.filter(
|
||||
manufacturer__name='Cisco',
|
||||
model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T']
|
||||
)
|
||||
)
|
||||
|
||||
def run(self, data):
|
||||
|
||||
# Create the new site
|
||||
site = Site(
|
||||
name=data['site_name'],
|
||||
slug=slugify(data['site_name']),
|
||||
status=SITE_STATUS_PLANNED
|
||||
)
|
||||
site.save()
|
||||
self.log_success("Created new site: {}".format(site))
|
||||
|
||||
# Create access switches
|
||||
switch_role = DeviceRole.objects.get(name='Access Switch')
|
||||
for i in range(1, data['switch_count'] + 1):
|
||||
switch = Device(
|
||||
device_type=data['switch_model'],
|
||||
name='{}-switch{}'.format(site.slug, i),
|
||||
site=site,
|
||||
status=DEVICE_STATUS_PLANNED,
|
||||
device_role=switch_role
|
||||
)
|
||||
switch.save()
|
||||
self.log_success("Created new switch: {}".format(switch))
|
||||
|
||||
# Generate a CSV table of new devices
|
||||
output = [
|
||||
'name,make,model'
|
||||
]
|
||||
for switch in Device.objects.filter(site=site):
|
||||
attrs = [
|
||||
switch.name,
|
||||
switch.device_type.manufacturer.name,
|
||||
switch.device_type.model
|
||||
]
|
||||
output.append(','.join(attrs))
|
||||
|
||||
return '\n'.join(output)
|
||||
```
|
@ -277,6 +277,14 @@ The file path to the location where custom reports will be kept. By default, thi
|
||||
|
||||
---
|
||||
|
||||
## SCRIPTS_ROOT
|
||||
|
||||
Default: $BASE_DIR/netbox/scripts/
|
||||
|
||||
The file path to the location where custom scripts will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path.
|
||||
|
||||
---
|
||||
|
||||
## SESSION_FILE_PATH
|
||||
|
||||
Default: None
|
||||
|
@ -384,3 +384,34 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
widget=ContentTypeSelect(),
|
||||
label='Object Type'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptForm(BootstrapMixin, forms.Form):
|
||||
_commit = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True,
|
||||
label="Commit changes",
|
||||
help_text="Commit changes to the database (uncheck for a dry-run)"
|
||||
)
|
||||
|
||||
def __init__(self, vars, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Dynamically populate fields for variables
|
||||
for name, var in vars.items():
|
||||
self.fields[name] = var.as_field()
|
||||
|
||||
# Move _commit to the end of the form
|
||||
self.fields.move_to_end('_commit', True)
|
||||
|
||||
@property
|
||||
def requires_input(self):
|
||||
"""
|
||||
A boolean indicating whether the form requires user input (ignore the _commit field).
|
||||
"""
|
||||
return bool(len(self.fields) > 1)
|
||||
|
@ -9,11 +9,12 @@ from django.utils import timezone
|
||||
from django.utils.functional import curry
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from extras.webhooks import enqueue_webhooks
|
||||
from .constants import (
|
||||
OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE,
|
||||
)
|
||||
from .models import ObjectChange
|
||||
from .signals import purge_changelog
|
||||
from .webhooks import enqueue_webhooks
|
||||
|
||||
_thread_locals = threading.local()
|
||||
|
||||
@ -30,6 +31,10 @@ def cache_changed_object(instance, **kwargs):
|
||||
|
||||
def _record_object_deleted(request, instance, **kwargs):
|
||||
|
||||
# TODO: Can we cache deletions for later processing like we do for saves? Currently this will trigger an exception
|
||||
# when trying to serialize ManyToMany relations after the object has been deleted. This should be doable if we alter
|
||||
# log_change() to return ObjectChanges to be saved rather than saving them directly.
|
||||
|
||||
# Record that the object was deleted
|
||||
if hasattr(instance, 'log_change'):
|
||||
instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
|
||||
@ -41,6 +46,13 @@ def _record_object_deleted(request, instance, **kwargs):
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
def purge_objectchange_cache(sender, **kwargs):
|
||||
"""
|
||||
Delete any queued object changes waiting to be written.
|
||||
"""
|
||||
_thread_locals.changed_objects = None
|
||||
|
||||
|
||||
class ObjectChangeMiddleware(object):
|
||||
"""
|
||||
This middleware performs three functions in response to an object being created, updated, or deleted:
|
||||
@ -74,9 +86,21 @@ class ObjectChangeMiddleware(object):
|
||||
post_save.connect(cache_changed_object, dispatch_uid='record_object_saved')
|
||||
post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted')
|
||||
|
||||
# Provide a hook for purging the change cache
|
||||
purge_changelog.connect(purge_objectchange_cache)
|
||||
|
||||
# Process the request
|
||||
response = self.get_response(request)
|
||||
|
||||
# If the change cache has been purged (e.g. due to an exception) abort the logging of all changes resulting from
|
||||
# this request.
|
||||
if _thread_locals.changed_objects is None:
|
||||
|
||||
# Delete ObjectChanges representing deletions, since these have already been written
|
||||
ObjectChange.objects.filter(request_id=request.id).delete()
|
||||
|
||||
return response
|
||||
|
||||
# Create records for any cached objects that were created/updated.
|
||||
for obj, action in _thread_locals.changed_objects:
|
||||
|
||||
|
23
netbox/extras/migrations/0024_scripts.py
Normal file
23
netbox/extras/migrations/0024_scripts.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2 on 2019-08-12 15:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0023_fix_tag_sequences'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Script',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
],
|
||||
options={
|
||||
'permissions': (('run_script', 'Can run script'),),
|
||||
'managed': False,
|
||||
},
|
||||
),
|
||||
]
|
@ -826,6 +826,21 @@ class ConfigContextModel(models.Model):
|
||||
return data
|
||||
|
||||
|
||||
#
|
||||
# Custom scripts
|
||||
#
|
||||
|
||||
class Script(models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
|
||||
"""
|
||||
class Meta:
|
||||
managed = False
|
||||
permissions = (
|
||||
('run_script', 'Can run script'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Report results
|
||||
#
|
||||
|
343
netbox/extras/scripts.py
Normal file
343
netbox/extras/scripts.py
Normal file
@ -0,0 +1,343 @@
|
||||
from collections import OrderedDict
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import pkgutil
|
||||
import time
|
||||
import traceback
|
||||
import yaml
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import transaction
|
||||
from mptt.forms import TreeNodeChoiceField
|
||||
from mptt.models import MPTTModel
|
||||
|
||||
from ipam.formfields import IPFormField
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
|
||||
from .forms import ScriptForm
|
||||
from .signals import purge_changelog
|
||||
|
||||
|
||||
__all__ = [
|
||||
'BaseScript',
|
||||
'BooleanVar',
|
||||
'FileVar',
|
||||
'IntegerVar',
|
||||
'IPNetworkVar',
|
||||
'ObjectVar',
|
||||
'Script',
|
||||
'StringVar',
|
||||
'TextVar',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Script variables
|
||||
#
|
||||
|
||||
class ScriptVariable:
|
||||
"""
|
||||
Base model for script variables
|
||||
"""
|
||||
form_field = forms.CharField
|
||||
|
||||
def __init__(self, label='', description='', default=None, required=True):
|
||||
|
||||
# Default field attributes
|
||||
self.field_attrs = {
|
||||
'help_text': description,
|
||||
'required': required
|
||||
}
|
||||
if label:
|
||||
self.field_attrs['label'] = label
|
||||
if default:
|
||||
self.field_attrs['initial'] = default
|
||||
|
||||
def as_field(self):
|
||||
"""
|
||||
Render the variable as a Django form field.
|
||||
"""
|
||||
form_field = self.form_field(**self.field_attrs)
|
||||
form_field.widget.attrs['class'] = 'form-control'
|
||||
|
||||
return form_field
|
||||
|
||||
|
||||
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'
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class IntegerVar(ScriptVariable):
|
||||
"""
|
||||
Integer representation. Can enforce minimum/maximum values.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
class BooleanVar(ScriptVariable):
|
||||
"""
|
||||
Boolean representation (true/false). Renders as a checkbox.
|
||||
"""
|
||||
form_field = forms.BooleanField
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Boolean fields cannot be required
|
||||
self.field_attrs['required'] = False
|
||||
|
||||
|
||||
class ObjectVar(ScriptVariable):
|
||||
"""
|
||||
NetBox object representation. The provided QuerySet will determine the choices available.
|
||||
"""
|
||||
form_field = forms.ModelChoiceField
|
||||
|
||||
def __init__(self, queryset, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Queryset for field choices
|
||||
self.field_attrs['queryset'] = queryset
|
||||
|
||||
# Update form field for MPTT (nested) objects
|
||||
if issubclass(queryset.model, MPTTModel):
|
||||
self.form_field = TreeNodeChoiceField
|
||||
|
||||
|
||||
class FileVar(ScriptVariable):
|
||||
"""
|
||||
An uploaded file.
|
||||
"""
|
||||
form_field = forms.FileField
|
||||
|
||||
|
||||
class IPNetworkVar(ScriptVariable):
|
||||
"""
|
||||
An IPv4 or IPv6 prefix.
|
||||
"""
|
||||
form_field = IPFormField
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class BaseScript:
|
||||
"""
|
||||
Base model for custom scripts. User classes should inherit from this model if they want to extend Script
|
||||
functionality for use in other subclasses.
|
||||
"""
|
||||
class Meta:
|
||||
pass
|
||||
|
||||
def __init__(self):
|
||||
|
||||
# Initiate the log
|
||||
self.log = []
|
||||
|
||||
# Grab some info about the script
|
||||
self.filename = inspect.getfile(self.__class__)
|
||||
self.source = inspect.getsource(self.__class__)
|
||||
|
||||
def __str__(self):
|
||||
return getattr(self.Meta, 'name', self.__class__.__name__)
|
||||
|
||||
def _get_vars(self):
|
||||
vars = OrderedDict()
|
||||
|
||||
# Infer order from Meta.field_order (Python 3.5 and lower)
|
||||
field_order = getattr(self.Meta, 'field_order', [])
|
||||
for name in field_order:
|
||||
vars[name] = getattr(self, name)
|
||||
|
||||
# Default to order of declaration on class
|
||||
for name, attr in self.__class__.__dict__.items():
|
||||
if name not in vars and issubclass(attr.__class__, ScriptVariable):
|
||||
vars[name] = attr
|
||||
|
||||
return vars
|
||||
|
||||
def run(self, data):
|
||||
raise NotImplementedError("The script must define a run() method.")
|
||||
|
||||
def as_form(self, data=None, files=None):
|
||||
"""
|
||||
Return a Django form suitable for populating the context data required to run this Script.
|
||||
"""
|
||||
vars = self._get_vars()
|
||||
form = ScriptForm(vars, data, files)
|
||||
|
||||
return form
|
||||
|
||||
# Logging
|
||||
|
||||
def log_debug(self, message):
|
||||
self.log.append((LOG_DEFAULT, message))
|
||||
|
||||
def log_success(self, message):
|
||||
self.log.append((LOG_SUCCESS, message))
|
||||
|
||||
def log_info(self, message):
|
||||
self.log.append((LOG_INFO, message))
|
||||
|
||||
def log_warning(self, message):
|
||||
self.log.append((LOG_WARNING, message))
|
||||
|
||||
def log_failure(self, message):
|
||||
self.log.append((LOG_FAILURE, message))
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
class Script(BaseScript):
|
||||
"""
|
||||
Classes which inherit this model will appear in the list of available scripts.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
#
|
||||
# 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
|
||||
|
||||
|
||||
def is_variable(obj):
|
||||
"""
|
||||
Returns True if the object is a ScriptVariable.
|
||||
"""
|
||||
return isinstance(obj, ScriptVariable)
|
||||
|
||||
|
||||
def run_script(script, data, files, commit=True):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
output = None
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
# Add files to form data
|
||||
for field_name, fileobj in files.items():
|
||||
data[field_name] = fileobj
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
start_time = time.time()
|
||||
output = script.run(data)
|
||||
end_time = time.time()
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
except AbortTransaction:
|
||||
pass
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
"An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
|
||||
)
|
||||
commit = False
|
||||
finally:
|
||||
if not commit:
|
||||
# Delete all pending changelog entries
|
||||
purge_changelog.send(Script)
|
||||
script.log_info(
|
||||
"Database changes have been reverted automatically."
|
||||
)
|
||||
|
||||
# Calculate execution time
|
||||
if end_time is not None:
|
||||
execution_time = end_time - start_time
|
||||
else:
|
||||
execution_time = None
|
||||
|
||||
return output, execution_time
|
||||
|
||||
|
||||
def get_scripts():
|
||||
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 hasattr(module, 'name'):
|
||||
module_name = module.name
|
||||
module_scripts = OrderedDict()
|
||||
for name, cls in inspect.getmembers(module, is_script):
|
||||
module_scripts[name] = cls
|
||||
scripts[module_name] = module_scripts
|
||||
|
||||
return scripts
|
@ -1,7 +1,12 @@
|
||||
from cacheops.signals import cache_invalidated, cache_read
|
||||
from django.dispatch import Signal
|
||||
from prometheus_client import Counter
|
||||
|
||||
|
||||
#
|
||||
# Caching
|
||||
#
|
||||
|
||||
cacheops_cache_hit = Counter('cacheops_cache_hit', 'Number of cache hits')
|
||||
cacheops_cache_miss = Counter('cacheops_cache_miss', 'Number of cache misses')
|
||||
cacheops_cache_invalidated = Counter('cacheops_cache_invalidated', 'Number of cache invalidations')
|
||||
@ -20,3 +25,10 @@ def cache_invalidated_collector(sender, obj_dict, **kwargs):
|
||||
|
||||
cache_read.connect(cache_read_collector)
|
||||
cache_invalidated.connect(cache_invalidated_collector)
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
||||
purge_changelog = Signal()
|
||||
|
37
netbox/extras/templatetags/log_levels.py
Normal file
37
netbox/extras/templatetags/log_levels.py
Normal file
@ -0,0 +1,37 @@
|
||||
from django import template
|
||||
|
||||
from extras.constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('extras/templatetags/log_level.html')
|
||||
def log_level(level):
|
||||
"""
|
||||
Display a label indicating a syslog severity (e.g. info, warning, etc.).
|
||||
"""
|
||||
levels = {
|
||||
LOG_DEFAULT: {
|
||||
'name': 'Default',
|
||||
'class': 'default'
|
||||
},
|
||||
LOG_SUCCESS: {
|
||||
'name': 'Success',
|
||||
'class': 'success',
|
||||
},
|
||||
LOG_INFO: {
|
||||
'name': 'Info',
|
||||
'class': 'info'
|
||||
},
|
||||
LOG_WARNING: {
|
||||
'name': 'Warning',
|
||||
'class': 'warning'
|
||||
},
|
||||
LOG_FAILURE: {
|
||||
'name': 'Failure',
|
||||
'class': 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
return levels[level]
|
157
netbox/extras/tests/test_scripts.py
Normal file
157
netbox/extras/tests/test_scripts.py
Normal file
@ -0,0 +1,157 @@
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from dcim.models import DeviceRole
|
||||
from extras.scripts import *
|
||||
|
||||
|
||||
class ScriptVariablesTest(TestCase):
|
||||
|
||||
def test_stringvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = StringVar(
|
||||
min_length=3,
|
||||
max_length=3,
|
||||
regex=r'[a-z]+'
|
||||
)
|
||||
|
||||
# Validate min_length enforcement
|
||||
data = {'var1': 'xx'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate max_length enforcement
|
||||
data = {'var1': 'xxxx'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate regex enforcement
|
||||
data = {'var1': 'ABC'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate valid data
|
||||
data = {'var1': 'abc'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], data['var1'])
|
||||
|
||||
def test_textvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = TextVar()
|
||||
|
||||
# Validate valid data
|
||||
data = {'var1': 'This is a test string'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], data['var1'])
|
||||
|
||||
def test_integervar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = IntegerVar(
|
||||
min_value=5,
|
||||
max_value=10
|
||||
)
|
||||
|
||||
# Validate min_value enforcement
|
||||
data = {'var1': 4}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate max_value enforcement
|
||||
data = {'var1': 11}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate valid data
|
||||
data = {'var1': 7}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], data['var1'])
|
||||
|
||||
def test_booleanvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = BooleanVar()
|
||||
|
||||
# Validate True
|
||||
data = {'var1': True}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], True)
|
||||
|
||||
# Validate False
|
||||
data = {'var1': False}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], False)
|
||||
|
||||
def test_objectvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = ObjectVar(
|
||||
queryset=DeviceRole.objects.all()
|
||||
)
|
||||
|
||||
# Populate some objects
|
||||
for i in range(1, 6):
|
||||
DeviceRole(
|
||||
name='Device Role {}'.format(i),
|
||||
slug='device-role-{}'.format(i)
|
||||
).save()
|
||||
|
||||
# Validate valid data
|
||||
data = {'var1': DeviceRole.objects.first().pk}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'].pk, data['var1'])
|
||||
|
||||
def test_filevar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = FileVar()
|
||||
|
||||
# Dummy file
|
||||
testfile = SimpleUploadedFile(
|
||||
name='test_file.txt',
|
||||
content=b'This is a dummy file for testing'
|
||||
)
|
||||
|
||||
# Validate valid data
|
||||
file_data = {'var1': testfile}
|
||||
form = TestScript().as_form(None, file_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], testfile)
|
||||
|
||||
def test_ipnetworkvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = IPNetworkVar()
|
||||
|
||||
# Validate IP network enforcement
|
||||
data = {'var1': '1.2.3'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate valid data
|
||||
data = {'var1': '192.0.2.0/24'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))
|
@ -28,13 +28,17 @@ urlpatterns = [
|
||||
path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
||||
path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
|
||||
|
||||
# Change logging
|
||||
path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||
path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
|
||||
|
||||
# Reports
|
||||
path(r'reports/', views.ReportListView.as_view(), name='report_list'),
|
||||
path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
|
||||
path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
|
||||
|
||||
# Change logging
|
||||
path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||
path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
|
||||
# Scripts
|
||||
path(r'scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||
path(r'scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||
|
||||
]
|
||||
|
@ -4,7 +4,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views.generic import View
|
||||
@ -20,6 +20,7 @@ from .forms import (
|
||||
)
|
||||
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
|
||||
from .reports import get_report, get_reports
|
||||
from .scripts import get_scripts, run_script
|
||||
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
|
||||
|
||||
|
||||
@ -353,3 +354,62 @@ class ReportRunView(PermissionRequiredMixin, View):
|
||||
messages.success(request, mark_safe(msg))
|
||||
|
||||
return redirect('extras:report', name=report.full_name)
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptListView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_script'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
return render(request, 'extras/script_list.html', {
|
||||
'scripts': get_scripts(),
|
||||
})
|
||||
|
||||
|
||||
class ScriptView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_script'
|
||||
|
||||
def _get_script(self, module, name):
|
||||
scripts = get_scripts()
|
||||
try:
|
||||
return scripts[module][name]()
|
||||
except KeyError:
|
||||
raise Http404
|
||||
|
||||
def get(self, request, module, name):
|
||||
|
||||
script = self._get_script(module, name)
|
||||
form = script.as_form()
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'module': module,
|
||||
'script': script,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
def post(self, request, module, name):
|
||||
|
||||
# Permissions check
|
||||
if not request.user.has_perm('extras.run_script'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
script = self._get_script(module, name)
|
||||
form = script.as_form(request.POST, request.FILES)
|
||||
output = None
|
||||
execution_time = None
|
||||
|
||||
if form.is_valid():
|
||||
commit = form.cleaned_data.pop('_commit')
|
||||
output, execution_time = run_script(script, form.cleaned_data, request.FILES, commit)
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'module': module,
|
||||
'script': script,
|
||||
'form': form,
|
||||
'output': output,
|
||||
'execution_time': execution_time,
|
||||
})
|
||||
|
@ -85,6 +85,7 @@ NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
|
||||
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
|
||||
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
|
||||
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
|
||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
|
||||
|
@ -529,6 +529,9 @@ table.report th a {
|
||||
border-top: 1px solid #dddddd;
|
||||
padding: 8px;
|
||||
}
|
||||
.rendered-markdown :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* AJAX loader */
|
||||
.loading {
|
||||
|
0
netbox/scripts/__init__.py
Normal file
0
netbox/scripts/__init__.py
Normal file
110
netbox/templates/extras/script.html
Normal file
110
netbox/templates/extras/script.html
Normal file
@ -0,0 +1,110 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
{% load log_levels %}
|
||||
|
||||
{% block title %}{{ script }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row noprint">
|
||||
<div class="col-md-12">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'extras:script_list' %}">Scripts</a></li>
|
||||
<li><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>
|
||||
<li>{{ script }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<h1>{{ script }}</h1>
|
||||
<p>{{ script.Meta.description }}</p>
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation" class="active">
|
||||
<a href="#run" role="tab" data-toggle="tab" class="active">Run</a>
|
||||
</li>
|
||||
<li role="presentation"{% if not output %} class="disabled"{% endif %}>
|
||||
<a href="#output" role="tab" data-toggle="tab">Output</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a href="#source" role="tab" data-toggle="tab">Source</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="run">
|
||||
{% if execution_time or script.log %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Script Log</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Level</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
{% for level, message in script.log %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>{% log_level level %}</td>
|
||||
<td class="rendered-markdown">{{ message|gfm }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted">
|
||||
No log output
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if execution_time %}
|
||||
<div class="panel-footer text-right text-muted">
|
||||
<small>Exec time: {{ execution_time|floatformat:3 }}s</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
{% if not perms.extras.run_script %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fa fa-warning"></i>
|
||||
You do not have permission to run scripts.
|
||||
</div>
|
||||
{% endif %}
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% if not form.requires_input %}
|
||||
<div class="alert alert-info">
|
||||
<i class="fa fa-exclamation-circle"></i>
|
||||
This script does not require any input to run.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Script Data</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% render_form form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pull-right">
|
||||
<button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="fa fa-play"></i> Run Script</button>
|
||||
<a href="{% url 'extras:script_list' %}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="output">
|
||||
<pre>{{ output }}</pre>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="source">
|
||||
<p><code>{{ script.filename }}</code></p>
|
||||
<pre>{{ script.source }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
38
netbox/templates/extras/script_list.html
Normal file
38
netbox/templates/extras/script_list.html
Normal file
@ -0,0 +1,38 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% block title %}Scripts{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% if scripts %}
|
||||
{% for module, module_scripts in scripts.items %}
|
||||
<h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
|
||||
<table class="table table-hover table-headings reports">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-md-3">Name</th>
|
||||
<th class="col-md-9">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for class_name, script in module_scripts.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'extras:script' module=module name=class_name %}" name="script.{{ class_name }}"><strong>{{ script }}</strong></a>
|
||||
</td>
|
||||
<td>{{ script.Meta.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<p><strong>No scripts found.</strong></p>
|
||||
<p>Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>. (This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.)</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
1
netbox/templates/extras/templatetags/log_level.html
Normal file
1
netbox/templates/extras/templatetags/log_level.html
Normal file
@ -0,0 +1 @@
|
||||
<label class="label label-{{ class }}">{{ name }}</label>
|
@ -66,6 +66,9 @@
|
||||
<li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
|
||||
</li>
|
||||
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:script_list' %}">Scripts</a>
|
||||
</li>
|
||||
<li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:report_list' %}">Reports</a>
|
||||
</li>
|
||||
|
5
netbox/utilities/exceptions.py
Normal file
5
netbox/utilities/exceptions.py
Normal file
@ -0,0 +1,5 @@
|
||||
class AbortTransaction(Exception):
|
||||
"""
|
||||
A dummy exception used to trigger a database transaction rollback.
|
||||
"""
|
||||
pass
|
Loading…
Reference in New Issue
Block a user