|
| 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