Skip to content

Commit 2e20d7f

Browse files
Merge pull request #7677 from netbox-community/6529-command-line-run-scripts
#6529 - Add CLI to run scripts
2 parents 831065b + b97167e commit 2e20d7f

File tree

2 files changed

+174
-0
lines changed

2 files changed

+174
-0
lines changed

docs/customization/custom-scripts.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,22 @@ http://netbox/api/extras/scripts/example.MyReport/ \
259259
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
260260
```
261261

262+
### Via the CLI
263+
264+
Scripts can be run on the CLI by invoking the management command:
265+
266+
```
267+
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
268+
```
269+
270+
The required ``<module>.<script>`` argument is the script to run where ``<module>`` is the name of the python file in the ``scripts`` directory without the ``.py`` extension and ``<script>`` is the name of the script class in the ``<module>`` to run.
271+
272+
The optional ``--data "<data>"`` argument is the data to send to the script
273+
274+
The optional ``--loglevel`` argument is the desired logging level to output to the console.
275+
276+
The optional ``--commit`` argument will commit any changes in the script to the database.
277+
262278
## Example
263279

264280
Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import json
2+
import logging
3+
import sys
4+
import traceback
5+
import uuid
6+
7+
from django.contrib.auth.models import User
8+
from django.contrib.contenttypes.models import ContentType
9+
from django.core.management.base import BaseCommand, CommandError
10+
from django.db import transaction
11+
12+
from extras.api.serializers import ScriptOutputSerializer
13+
from extras.choices import JobResultStatusChoices
14+
from extras.context_managers import change_logging
15+
from extras.models import JobResult
16+
from extras.scripts import get_script
17+
from utilities.exceptions import AbortTransaction
18+
from utilities.utils import NetBoxFakeRequest
19+
20+
21+
class Command(BaseCommand):
22+
help = "Run a script in Netbox"
23+
24+
def add_arguments(self, parser):
25+
parser.add_argument(
26+
'--loglevel',
27+
help="Logging Level (default: info)",
28+
dest='loglevel',
29+
default='info',
30+
choices=['debug', 'info', 'warning', 'error', 'critical'])
31+
parser.add_argument('--commit', help="Commit this script to database", action='store_true')
32+
parser.add_argument('--user', help="User script is running as")
33+
parser.add_argument('--data', help="Data as a string encapsulated JSON blob")
34+
parser.add_argument('script', help="Script to run")
35+
36+
def handle(self, *args, **options):
37+
def _run_script():
38+
"""
39+
Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
40+
the change_logging context manager (which is bypassed if commit == False).
41+
"""
42+
try:
43+
with transaction.atomic():
44+
script.output = script.run(data=data, commit=commit)
45+
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
46+
47+
if not commit:
48+
raise AbortTransaction()
49+
50+
except AbortTransaction:
51+
script.log_info("Database changes have been reverted automatically.")
52+
53+
except Exception as e:
54+
stacktrace = traceback.format_exc()
55+
script.log_failure(
56+
f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
57+
)
58+
script.log_info("Database changes have been reverted due to error.")
59+
logger.error(f"Exception raised during script execution: {e}")
60+
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
61+
62+
finally:
63+
job_result.data = ScriptOutputSerializer(script).data
64+
job_result.save()
65+
66+
logger.info(f"Script completed in {job_result.duration}")
67+
68+
# Params
69+
script = options['script']
70+
loglevel = options['loglevel']
71+
commit = options['commit']
72+
try:
73+
data = json.loads(options['data'])
74+
except TypeError:
75+
data = {}
76+
77+
module, name = script.split('.', 1)
78+
79+
# Take user from command line if provided and exists, other
80+
if options['user']:
81+
try:
82+
user = User.objects.get(username=options['user'])
83+
except User.DoesNotExist:
84+
user = User.objects.filter(is_superuser=True).order_by('pk')[0]
85+
else:
86+
user = User.objects.filter(is_superuser=True).order_by('pk')[0]
87+
88+
# Setup logging to Stdout
89+
formatter = logging.Formatter(f'[%(asctime)s][%(levelname)s] - %(message)s')
90+
stdouthandler = logging.StreamHandler(sys.stdout)
91+
stdouthandler.setLevel(logging.DEBUG)
92+
stdouthandler.setFormatter(formatter)
93+
94+
logger = logging.getLogger(f"netbox.scripts.{module}.{name}")
95+
logger.addHandler(stdouthandler)
96+
97+
try:
98+
logger.setLevel({
99+
'critical': logging.CRITICAL,
100+
'debug': logging.DEBUG,
101+
'error': logging.ERROR,
102+
'fatal': logging.FATAL,
103+
'info': logging.INFO,
104+
'warning': logging.WARNING,
105+
}[loglevel])
106+
except KeyError:
107+
raise CommandError(f"Invalid log level: {loglevel}")
108+
109+
# Get the script
110+
script = get_script(module, name)()
111+
# Parse the parameters
112+
form = script.as_form(data, None)
113+
114+
script_content_type = ContentType.objects.get(app_label='extras', model='script')
115+
116+
# Delete any previous terminal state results
117+
JobResult.objects.filter(
118+
obj_type=script_content_type,
119+
name=script.full_name,
120+
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
121+
).delete()
122+
123+
# Create the job result
124+
job_result = JobResult.objects.create(
125+
name=script.full_name,
126+
obj_type=script_content_type,
127+
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
128+
job_id=uuid.uuid4()
129+
)
130+
131+
request = NetBoxFakeRequest({
132+
'META': {},
133+
'POST': data,
134+
'GET': {},
135+
'FILES': {},
136+
'user': user,
137+
'path': '',
138+
'id': job_result.job_id
139+
})
140+
141+
if form.is_valid():
142+
job_result.status = JobResultStatusChoices.STATUS_RUNNING
143+
job_result.save()
144+
145+
logger.info(f"Running script (commit={commit})")
146+
script.request = request
147+
148+
# Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
149+
# change logging, webhooks, etc.
150+
with change_logging(request):
151+
_run_script()
152+
else:
153+
logger.error('Data is not valid:')
154+
for field, errors in form.errors.get_json_data().items():
155+
for error in errors:
156+
logger.error(f'\t{field}: {error.get("message")}')
157+
job_result.status = JobResultStatusChoices.STATUS_ERRORED
158+
job_result.save()

0 commit comments

Comments
 (0)