Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next 2.0 #305

Merged
merged 30 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c17166c
copy init-jobs changes fom #224 with rebase to next-2.0 branch
Sep 26, 2023
67d545d
Rebased PR for next-2.0 branch, had some trouble with fork
Sep 26, 2023
e01dbe6
Correct change fragment, #270 will replace #224, and closes #223
Sep 26, 2023
99e4512
Add kwargs for get_jobs() that allows user-specified header items to …
Sep 26, 2023
0d623ef
Minor update
Sep 26, 2023
0f28144
Catch job_result.result == "FAILURE"
Sep 26, 2023
9d99ca7
run black
Sep 26, 2023
4d0d877
pydocstyle, flake8 fixes
Sep 26, 2023
fe07fc3
black formatter
Sep 26, 2023
1c8da1a
pylint
Sep 26, 2023
3fc453a
remove runnable references
Sep 26, 2023
d7e4ccc
reuse existing user instance (dispatch.user)
Sep 27, 2023
1423b8c
typo correction for dispatcher.user
Sep 27, 2023
50725bb
Replace list comp with generator
smk4664 Sep 29, 2023
42e1931
Add init-job-form nautobot subcommand
Oct 31, 2023
f074f92
flake8 fix, catch explicit exception
Oct 31, 2023
6910196
Remove block response, leaving testing commented
Oct 31, 2023
98c2db3
pylint8 fixes
Oct 31, 2023
9a259a5
CI history expired, re-running checks
Feb 5, 2024
8dd56fe
pylint CI fixes: W1113, R0914, C0301
Feb 5, 2024
195c888
Black code formatter
Feb 6, 2024
28c1d95
pylint ignore too-many-locals for init_job
Feb 6, 2024
cb26441
Replace execute_job with enqueue_job, which is correct Nautobot 2.x p…
Feb 6, 2024
64ed936
Fix missing variable
Feb 6, 2024
00eb275
Update breadcrumb changes
Feb 6, 2024
7470ec8
Add max iterations for waiting on job to enter ready state in the dat…
Feb 6, 2024
0ab7e78
Rename `init_job` subcommand to `run_job`
Feb 6, 2024
875ca64
Update error for initiated job status failure state
Feb 7, 2024
e6c14c6
Rub Black formatter
Feb 7, 2024
eb31930
Merge pull request #270 from MeganerdDev/jobs-patch
smk4664 Mar 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changes/270.added
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Add run_job Nautobot subcommand, which initiates a job with kwargs or a job requiring no manual form input.
Add run_job_form Nautobot subcommand, which presents job's form widgets to the user.
Add get_jobs Nautobot subcommand, which returns all Nautobot jobs viewable to user.
Add filter_jobs Nautobot subcommand, which returns filtered set of Nautobot jobs viewable to user.
348 changes: 347 additions & 1 deletion nautobot_chatops/workers/nautobot.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Worker functions for interacting with Nautobot."""

import json
import time


from django.core.exceptions import ValidationError
from django.db.models import Count
from django.contrib.contenttypes.models import ContentType
Expand All @@ -10,7 +14,9 @@
from nautobot.dcim.models import Device, DeviceType, Location, LocationType, Manufacturer, Rack, Cable
from nautobot.ipam.models import VLAN, Prefix, VLANGroup
from nautobot.tenancy.models import Tenant
from nautobot.extras.models import Role, Status
from nautobot.extras.choices import JobResultStatusChoices
from nautobot.extras.models import Job, JobResult, Role, Status
from nautobot.extras.jobs import get_job

from nautobot_chatops.choices import CommandStatusChoices
from nautobot_chatops.workers import subcommand_of, handle_subcommands
Expand Down Expand Up @@ -1045,6 +1051,346 @@ def get_circuit_providers(dispatcher, *args):
return CommandStatusChoices.STATUS_SUCCEEDED


@subcommand_of("nautobot")
def filter_jobs(dispatcher, job_filters: str = ""): # We can use a Literal["enabled", "installed"] here instead
"""Get a filtered list of jobs from Nautobot that the request user have view permissions for.

Args:
job_filters (str): Filter job results by literals in a comma-separated string.
Available filters are: enabled, installed.
"""
# Check for filters in user supplied input
job_filters_list = [item.strip() for item in job_filters.split(",")] if isinstance(job_filters, str) else ""
filters = ["enabled", "installed"]
if any(key in job_filters for key in filters):
filter_args = {key: True for key in filters if key in job_filters_list}
jobs = Job.objects.restrict(dispatcher.user, "view").filter(**filter_args) # enabled=True, installed=True
else:
jobs = Job.objects.restrict(dispatcher.user, "view").all()

header = ["Name", "ID", "Enabled"]
rows = [
(
str(job.name),
str(job.id),
str(job.enabled),
)
for job in jobs
]

dispatcher.send_large_table(header, rows)

return CommandStatusChoices.STATUS_SUCCEEDED


@subcommand_of("nautobot")
def get_jobs(dispatcher, kwargs: str = ""):
"""Get all jobs from Nautobot that the requesting user have view permissions for.

Args:
kwargs (str): JSON-string array of header items to be exported. (Optional, default export is: name, id, enabled)
"""
# Confirm kwargs is valid JSON
json_args = ["name", "id", "enabled"]
try:
if kwargs:
json_args = json.loads(kwargs)
except json.JSONDecodeError:
dispatcher.send_error(f"Invalid JSON-string, cannot decode: {kwargs}")
return (CommandStatusChoices.STATUS_FAILED, f"Invalid JSON-string, cannot decode: {kwargs}")

# Confirm `name` is always present in export
name_key = json_args.get("name") or json_args.get("Name")
if not name_key:
json_args.append("name")

jobs = Job.objects.restrict(dispatcher.user, "view").all()

# Check if all items in json_args are valid keys (assuming all keys of job object are valid)
valid_keys = [attr for attr in dir(Job) if not callable(getattr(Job, attr)) and not attr.startswith("_")]
for item in json_args:
if item.lower() not in valid_keys:
dispatcher.send_error(f"Invalid item provided: {item.lower()}")
return (CommandStatusChoices.STATUS_FAILED, f"Invalid item provided: {item.lower()}")

# TODO: Check json_args are all valid keys
header = [item.capitalize() for item in json_args]
rows = [(tuple(str(getattr(job, item, "")) for item in json_args)) for job in jobs]

dispatcher.send_large_table(header, rows)

return CommandStatusChoices.STATUS_SUCCEEDED


@subcommand_of("nautobot")
def run_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = ""): # pylint: disable=too-many-locals
"""Initiate a job in Nautobot by job name.

Args:
*args (tuple): Dispatcher form will pass job args as tuple.
job_name (str): Name of Nautobot job to run.
json_string_kwargs (str): JSON-string dictionary for input keyword arguments for job run.
"""
# Prompt the user to pick a job if they did not specify one
if not job_name:
return prompt_for_job(dispatcher, "nautobot run-job")

if args:
json_string_kwargs = "{}"

# Confirm kwargs is valid JSON
json_args = {}
try:
if json_string_kwargs:
json_args = json.loads(json_string_kwargs)
except json.JSONDecodeError:
dispatcher.send_error(f"Invalid JSON-string, cannot decode: {json_string_kwargs}")
return (CommandStatusChoices.STATUS_FAILED, f"Invalid JSON-string, cannot decode: {json_string_kwargs}")

profile = False
if json_args.get("profile") and json_args["profile"] is True:
profile = True

# Get the job model instance using job name
try:
job_model = Job.objects.restrict(dispatcher.user, "view").get(name=job_name)
except Job.DoesNotExist:
dispatcher.send_error(f"Job {job_name} not found")
return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" was not found')

if not job_model.enabled:
dispatcher.send_error(f"The requested job {job_name} is not enabled")
return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" is not enabled')

form_class = get_job(job_model.class_path).as_form()

# Parse base form fields from job class
form_fields = []
for field_name, _ in form_class.base_fields.items(): # pylint: disable=unused-variable
if field_name.startswith("_"):
continue
form_fields.append(f"{field_name}")

# Basic logic check with what we know, we should expect run-job-form vs run-job to parse the same base fields
if len(form_fields) != len(args):
dispatcher.send_error("The form class fields and the passed run-job args do not match.")
return (
CommandStatusChoices.STATUS_FAILED,
"The form class fields and the passed run-job args do not match.",
)

form_item_kwargs = {}
for index, _ in enumerate(form_fields): # pylint: disable=unused-variable
# Check request input (string-type) is also valid JSON
if args[index][0] == "{":
try:
json_arg = json.loads(args[index])
if not json_arg.get("id"):
dispatcher.send_error("Form field arg is JSON dictionary, and has no `id` key.")
return (
CommandStatusChoices.STATUS_FAILED,
"Form field arg is JSON dictionary, and has no `id` key.",
)
form_item_kwargs[form_fields[index]] = json_arg.get("id")
continue
except json.JSONDecodeError:
form_item_kwargs[form_fields[index]] = args[index]
continue
form_item_kwargs[form_fields[index]] = args[index]

job_result = JobResult.enqueue_job(
job_model=job_model,
user=dispatcher.user,
profile=profile,
**form_item_kwargs,
)

# Wait on the job to finish
max_wait_iterations = 60
while job_result.status not in JobResultStatusChoices.READY_STATES:
max_wait_iterations -= 1
if not max_wait_iterations:
dispatcher.send_error(f"The requested job {job_name} failed to reach ready state.")
return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" failed to reach ready state.')
time.sleep(1)
job_result.refresh_from_db()

if job_result and job_result.status == "FAILURE":
dispatcher.send_error(f"The requested job {job_name} was initiated but failed. Result: {job_result.result}")
return (
CommandStatusChoices.STATUS_FAILED,
f'Job "{job_name}" was initiated but failed. Result: {job_result.result}',
) # pylint: disable=line-too-long

job_url = (
f"{dispatcher.context['request_scheme']}://{dispatcher.context['request_host']}{job_result.get_absolute_url()}"
)
blocks = [
dispatcher.markdown_block(
f"The requested job {job_model.class_path} was initiated! [`click here`]({job_url}) to open the job."
),
]

dispatcher.send_blocks(blocks)

return CommandStatusChoices.STATUS_SUCCEEDED


@subcommand_of("nautobot")
def run_job_form(dispatcher, job_name: str = ""):
"""Send job form as a multi-input dialog. On form submit it initiates the job with the form arguments.

Args:
job_name (str): Name of Nautobot job to run.
"""
# Prompt the user to pick a job if they did not specify one
if not job_name:
return prompt_for_job(dispatcher, "nautobot run-job-form")

# Get jobs available to user
try:
job = Job.objects.restrict(dispatcher.user, "view").get(name=job_name)
except Job.DoesNotExist:
blocks = [
dispatcher.markdown_block(
f"Job {job_name} does not exist or {dispatcher.user} does not have permissions to run job." # pylint: disable=line-too-long
),
]
dispatcher.send_blocks(blocks)
return CommandStatusChoices.STATUS_SUCCEEDED

except Job.MultipleObjectsReturned:
blocks = [
dispatcher.markdown_block(f"Multiple jobs found by name {job_name}."),
]
dispatcher.send_blocks(blocks)
return CommandStatusChoices.STATUS_SUCCEEDED

if not job.enabled:
blocks = [
dispatcher.markdown_block(f"Job {job_name} is not enabled. The job must be enabled to be ran."),
]
dispatcher.send_blocks(blocks)
return CommandStatusChoices.STATUS_SUCCEEDED

form_class = get_job(job.class_path).as_form()

# Parse base form fields from job class
form_items = {}
for field_name, field in form_class.base_fields.items():
if field_name.startswith("_"):
continue
form_items[field_name] = field

form_item_dialogs = []
for field_name, field in form_items.items():
try:
field_type = field.widget.input_type
except AttributeError:
# Some widgets (eg: textarea) do have the `input_type` attribute
field_type = field.widget.template_name.split("/")[-1].split(".")[0]

if field_type == "select":
if not hasattr(field, "choices"):
blocks = [
dispatcher.markdown_block(f"Job {job_name} field {field} has no attribute `choices`."),
]
dispatcher.send_blocks(blocks)
return CommandStatusChoices.STATUS_SUCCEEDED

query_result_items = []
for choice, value in field.choices:
query_result_items.append(
(value, f'{{"field_name": "{field_name}", "value": "{value}", "id": "{str(choice)}"}}')
)

if len(query_result_items) == 0 and field.required:
blocks = [
dispatcher.markdown_block(
f"Job {job_name} for {field_name} is required, however no choices populated for dialog choices."
),
]
dispatcher.send_blocks(blocks)
return CommandStatusChoices.STATUS_SUCCEEDED

form_item_dialogs.append(
{
"type": field_type,
"label": f"{field_name}: {field.help_text}",
"choices": query_result_items,
"default": query_result_items[0] if query_result_items else ("", ""),
"confirm": False,
}
)

elif field_type == "text":
default_value = field.initial
form_item_dialogs.append(
{
"type": field_type,
"label": f"{field_name}: {field.help_text}",
"default": default_value,
"confirm": False,
}
)

elif field_type == "number":
# TODO: Can we enforce numeric-character mask for widget input without JavaScript?
default_value = field.initial
form_item_dialogs.append(
{
"type": "text",
"label": f"{field_name}: {field.help_text} *integer values only*",
"default": default_value,
"confirm": False,
}
)

elif field_type == "checkbox":
# TODO: Is there a checkbox widget?
default_value = ("False", "false")
if field.initial:
default_value = ("True", "true")
form_item_dialogs.append(
{
"type": "select",
"label": f"{field_name}: {field.help_text}",
"choices": [("True", "true"), ("False", "false")],
"default": default_value,
"confirm": False,
}
)

elif field_type == "textarea":
# TODO: Is there a multi-line text input widget
default_value = field.initial
form_item_dialogs.append(
{
"type": "text",
"label": f"{field_name}: {field.help_text}",
"default": default_value,
"confirm": False,
}
)

# TODO: BUG: Single inputs will present but not submit properly with multi_input_dialog
dispatcher.multi_input_dialog(
command="nautobot",
sub_command=f"run-job {job_name} {{}}",
dialog_title=f"job {job_name} form input",
dialog_list=form_item_dialogs,
)

return CommandStatusChoices.STATUS_SUCCEEDED


def prompt_for_job(dispatcher, command):
"""Prompt the user to select a Nautobot Job."""
jobs = Job.objects.restrict(dispatcher.user, "view").all()
dispatcher.prompt_from_menu(command, "Select a Nautobot Job", [(job.name, job.name) for job in jobs])
return False


@subcommand_of("nautobot")
def about(dispatcher, *args):
"""Provide link for more information on Nautobot Apps."""
Expand Down
Loading