nautobot-workflow-engine 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nautobot_workflow_engine/__init__.py +35 -0
- nautobot_workflow_engine/app-config-schema.json +31 -0
- nautobot_workflow_engine/datasources.py +257 -0
- nautobot_workflow_engine/executors.py +1138 -0
- nautobot_workflow_engine/filters.py +23 -0
- nautobot_workflow_engine/forms.py +745 -0
- nautobot_workflow_engine/jobs.py +211 -0
- nautobot_workflow_engine/migrations/0001_initial.py +152 -0
- nautobot_workflow_engine/migrations/__init__.py +0 -0
- nautobot_workflow_engine/models.py +271 -0
- nautobot_workflow_engine/navigation.py +36 -0
- nautobot_workflow_engine/scheduling.py +184 -0
- nautobot_workflow_engine/servicenow.py +348 -0
- nautobot_workflow_engine/static/nautobot_workflow_engine/js/workflow_list.js +116 -0
- nautobot_workflow_engine/tables.py +22 -0
- nautobot_workflow_engine/tasks.py +135 -0
- nautobot_workflow_engine/templates/nautobot_workflow_engine/partials/workflow_list_grouped.html +92 -0
- nautobot_workflow_engine/templates/nautobot_workflow_engine/partials/workflow_tiles.html +129 -0
- nautobot_workflow_engine/templates/nautobot_workflow_engine/workflow_launch.html +2972 -0
- nautobot_workflow_engine/templates/nautobot_workflow_engine/workflow_list.html +144 -0
- nautobot_workflow_engine/templates/nautobot_workflow_engine/workflow_report.html +560 -0
- nautobot_workflow_engine/templates/nautobot_workflow_engine/workflow_run.html +535 -0
- nautobot_workflow_engine/templates/nautobot_workflow_engine/workflow_run_list.html +331 -0
- nautobot_workflow_engine/templatetags/__init__.py +2 -0
- nautobot_workflow_engine/templatetags/workflow_extras.py +38 -0
- nautobot_workflow_engine/tests/__init__.py +1 -0
- nautobot_workflow_engine/tests/test_action_ordering.py +102 -0
- nautobot_workflow_engine/tests/test_basic.py +30 -0
- nautobot_workflow_engine/tests/test_executors.py +137 -0
- nautobot_workflow_engine/tests/test_forms_listdict.py +110 -0
- nautobot_workflow_engine/tests/test_repo_filters.py +143 -0
- nautobot_workflow_engine/tests/test_servicenow_scheduling.py +291 -0
- nautobot_workflow_engine/urls.py +27 -0
- nautobot_workflow_engine/views.py +1381 -0
- nautobot_workflow_engine-1.0.1.dist-info/METADATA +88 -0
- nautobot_workflow_engine-1.0.1.dist-info/RECORD +37 -0
- nautobot_workflow_engine-1.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""App declaration for nautobot_workflow_engine."""
|
|
2
|
+
|
|
3
|
+
# Metadata is inherited from Nautobot. If not including Nautobot in the environment, this should be added
|
|
4
|
+
import os
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
|
|
7
|
+
from nautobot.apps import NautobotAppConfig
|
|
8
|
+
|
|
9
|
+
__version__ = metadata.version(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkflowEngineConfig(NautobotAppConfig):
|
|
13
|
+
"""App configuration for the nautobot_workflow_engine app."""
|
|
14
|
+
|
|
15
|
+
name = "nautobot_workflow_engine"
|
|
16
|
+
verbose_name = "Workflow Engine"
|
|
17
|
+
version = __version__
|
|
18
|
+
author = "Eric Fetty"
|
|
19
|
+
description = "General-purpose workflow engine for Nautobot"
|
|
20
|
+
base_url = "workflow-engine"
|
|
21
|
+
required_settings = []
|
|
22
|
+
default_settings = {
|
|
23
|
+
"awx_external_integration_name": "AWX",
|
|
24
|
+
"workflow_dir": "workflows",
|
|
25
|
+
"filters_dir": "filters",
|
|
26
|
+
"awx_poll_seconds": 5,
|
|
27
|
+
"show_disabled": False,
|
|
28
|
+
"show_add_new_item": os.getenv("SHOW_ADD_NEW_ITEM", "").split(","),
|
|
29
|
+
}
|
|
30
|
+
caching_config = {}
|
|
31
|
+
docs_view_name = "plugins:nautobot_workflow_engine:docs"
|
|
32
|
+
searchable_models = ["workflow"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
config = WorkflowEngineConfig # pylint:disable=invalid-name
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"awx_external_integration_name": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"default": "AWX"
|
|
8
|
+
},
|
|
9
|
+
"workflow_dir": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"default": "workflows"
|
|
12
|
+
},
|
|
13
|
+
"filters_dir": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"default": "filters"
|
|
16
|
+
},
|
|
17
|
+
"awx_poll_seconds": {
|
|
18
|
+
"type": "integer",
|
|
19
|
+
"default": 5
|
|
20
|
+
},
|
|
21
|
+
"show_disabled": {
|
|
22
|
+
"type": "boolean",
|
|
23
|
+
"default": false
|
|
24
|
+
},
|
|
25
|
+
"show_add_new_item": {
|
|
26
|
+
"type": "array",
|
|
27
|
+
"default": []
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"additionalProperties": false
|
|
31
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Git repository sync functionality for workflows."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.contrib.contenttypes.models import ContentType
|
|
9
|
+
from nautobot.apps.datasources import DatasourceContent
|
|
10
|
+
from nautobot.extras.models import GitRepository
|
|
11
|
+
|
|
12
|
+
from .executors import clear_repo_script_cache, validate_repo_filter_modules
|
|
13
|
+
from .models import Workflow, WorkflowAction, WorkflowFilterModule, WorkflowHelper, WorkflowInput
|
|
14
|
+
|
|
15
|
+
PLUGIN_CFG = settings.PLUGINS_CONFIG.get("nautobot_workflow_engine", {})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _process_includes(job_result, file_handler):
|
|
19
|
+
"""Process any includes in the content."""
|
|
20
|
+
content = file_handler.read_text(encoding="utf-8")
|
|
21
|
+
include_pattern = re.compile(r"""^\s*{%\s*include\s+[\"'](?P<path>[\w\/\.\-]+)[\"']\s*%}""")
|
|
22
|
+
for line in content.splitlines():
|
|
23
|
+
match = include_pattern.search(line)
|
|
24
|
+
if match:
|
|
25
|
+
# Load the template path from the match
|
|
26
|
+
template_path = match.group("path")
|
|
27
|
+
template_file = file_handler.parent / template_path
|
|
28
|
+
if template_file.exists():
|
|
29
|
+
# If the template file exists, read its content and replace the include
|
|
30
|
+
template_content = template_file.read_text(encoding="utf-8")
|
|
31
|
+
content = content.replace(line, template_content)
|
|
32
|
+
else:
|
|
33
|
+
# If the template file doesn't exist, log a warning
|
|
34
|
+
job_result.log(f"Template file not found: {template_file}")
|
|
35
|
+
|
|
36
|
+
return content
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_action_list(data, *keys):
|
|
40
|
+
"""Return first non-empty action list for any provided YAML key alias."""
|
|
41
|
+
for key in keys:
|
|
42
|
+
value = data.get(key)
|
|
43
|
+
if value is None:
|
|
44
|
+
continue
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
return value
|
|
47
|
+
# Be forgiving if user accidentally provides an empty scalar/object.
|
|
48
|
+
return []
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_filter_module_path(relative_path):
|
|
53
|
+
"""Return True when the repository-relative path is inside the configured filters directory."""
|
|
54
|
+
filters_dir = Path(PLUGIN_CFG.get("filters_dir", "filters"))
|
|
55
|
+
filters_parts = filters_dir.parts
|
|
56
|
+
rel_parts = relative_path.parts
|
|
57
|
+
return len(rel_parts) >= len(filters_parts) and rel_parts[: len(filters_parts)] == filters_parts
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sync_repo_python_modules(repository_record, repo_path, job_result):
|
|
61
|
+
"""Store repository Python modules in helper or filter-module models."""
|
|
62
|
+
WorkflowHelper.objects.filter(source_repo=repository_record).delete()
|
|
63
|
+
WorkflowFilterModule.objects.filter(source_repo=repository_record).delete()
|
|
64
|
+
|
|
65
|
+
for py_file in repo_path.rglob("*.py"):
|
|
66
|
+
if "__pycache__" in str(py_file) or py_file.name.startswith("."):
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
relative_path = py_file.relative_to(repo_path)
|
|
70
|
+
model = WorkflowFilterModule if _is_filter_module_path(relative_path) else WorkflowHelper
|
|
71
|
+
model.objects.create(
|
|
72
|
+
source_repo=repository_record,
|
|
73
|
+
file_path=str(relative_path),
|
|
74
|
+
content=py_file.read_text(encoding="utf-8"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if model is WorkflowFilterModule:
|
|
78
|
+
job_result.log(message=f"Stored filter module: {relative_path}")
|
|
79
|
+
else:
|
|
80
|
+
job_result.log(message=f"Stored Python file: {relative_path}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def sync_workflows_from_repo(repository_record: GitRepository, job_result, delete=False):
|
|
84
|
+
"""Sync workflow definitions and helper files from a Git repository."""
|
|
85
|
+
repo_path = Path(repository_record.filesystem_path)
|
|
86
|
+
wf_dir = PLUGIN_CFG["workflow_dir"]
|
|
87
|
+
workflows_path = repo_path / wf_dir
|
|
88
|
+
if not workflows_path.exists():
|
|
89
|
+
clear_repo_script_cache(repository_record)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# Track workflows found in this sync
|
|
93
|
+
synced_workflow_keys = set()
|
|
94
|
+
|
|
95
|
+
_sync_repo_python_modules(repository_record, repo_path, job_result)
|
|
96
|
+
validate_repo_filter_modules(repository_record)
|
|
97
|
+
|
|
98
|
+
for yml in sorted(list(workflows_path.glob("*.y*ml"))):
|
|
99
|
+
content = _process_includes(job_result, yml)
|
|
100
|
+
data = yaml.safe_load(content)
|
|
101
|
+
|
|
102
|
+
# Track this workflow key
|
|
103
|
+
workflow_key = data.get("key")
|
|
104
|
+
if workflow_key:
|
|
105
|
+
synced_workflow_keys.add(workflow_key)
|
|
106
|
+
|
|
107
|
+
# Check if workflow is explicitly disabled in YAML
|
|
108
|
+
yaml_enabled = data.get("enabled", True)
|
|
109
|
+
|
|
110
|
+
wf, _ = Workflow.objects.update_or_create(
|
|
111
|
+
key=data["key"],
|
|
112
|
+
defaults={
|
|
113
|
+
"name": data.get("name", data["key"]),
|
|
114
|
+
"description": data.get("description", ""),
|
|
115
|
+
"category": data.get("category", ""),
|
|
116
|
+
"enabled": yaml_enabled, # Use enabled flag from YAML
|
|
117
|
+
"manual_duration_minutes": data.get("manual_duration_minutes"),
|
|
118
|
+
"raw_definition": data,
|
|
119
|
+
"source_repo": repository_record,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
wf.inputs.all().delete()
|
|
123
|
+
for order, ip in enumerate(data.get("inputs", []), start=1):
|
|
124
|
+
ct = None
|
|
125
|
+
if ip.get("type") in ("object", "multiobject") and ip.get("model"):
|
|
126
|
+
app_label, model = ip["model"].split(".")
|
|
127
|
+
try:
|
|
128
|
+
ct = ContentType.objects.get(app_label=app_label, model=model.lower())
|
|
129
|
+
except ContentType.DoesNotExist:
|
|
130
|
+
raise ValueError(f"[Workflow {wf.key}] Invalid model for input {ip['key']}: {ip['model']}")
|
|
131
|
+
|
|
132
|
+
# Normalize validators:
|
|
133
|
+
# - support either a single "regex" (with optional message/flags near root)
|
|
134
|
+
# - or a list under "validators", e.g. [{"regex": "...", "flags": ["IGNORECASE"], "message": "..."}]
|
|
135
|
+
validators = ip.get("validators")
|
|
136
|
+
if not validators:
|
|
137
|
+
if "regex" in ip:
|
|
138
|
+
v = {"regex": ip.get("regex")}
|
|
139
|
+
if "message" in ip:
|
|
140
|
+
v["message"] = ip["message"]
|
|
141
|
+
if "flags" in ip:
|
|
142
|
+
v["flags"] = ip["flags"]
|
|
143
|
+
validators = [v]
|
|
144
|
+
|
|
145
|
+
WorkflowInput.objects.create(
|
|
146
|
+
workflow=wf,
|
|
147
|
+
key=ip["key"],
|
|
148
|
+
label=ip.get("label", ip["key"]),
|
|
149
|
+
input_type=ip["type"],
|
|
150
|
+
content_type=ct,
|
|
151
|
+
required=ip.get("required", False),
|
|
152
|
+
default=ip.get("default"),
|
|
153
|
+
depends_on=ip.get("depends_on", []),
|
|
154
|
+
filter_query=ip.get("filter", {}).get("query"),
|
|
155
|
+
choices=ip.get("choices"),
|
|
156
|
+
columns=ip.get("columns"),
|
|
157
|
+
visible_when=ip.get("show_if"),
|
|
158
|
+
validators=validators,
|
|
159
|
+
order=order,
|
|
160
|
+
)
|
|
161
|
+
# Use bulk deletion to avoid get_absolute_url issues
|
|
162
|
+
WorkflowAction.objects.filter(workflow=wf).delete()
|
|
163
|
+
|
|
164
|
+
phased_actions = [
|
|
165
|
+
("pre", _get_action_list(data, "pre_actions", "pre-actions", "before_actions", "before-actions")),
|
|
166
|
+
# backwards-compatible existing key
|
|
167
|
+
("main", _get_action_list(data, "actions", "main_actions", "main-actions")),
|
|
168
|
+
("post", _get_action_list(data, "post_actions", "post-actions", "after_actions", "after-actions")),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
for phase, actions in phased_actions:
|
|
172
|
+
for order, act in enumerate(actions, start=1):
|
|
173
|
+
|
|
174
|
+
if act.get("type") == "nautobot":
|
|
175
|
+
script_path = (act.get("script") or "").lstrip("/")
|
|
176
|
+
if not script_path:
|
|
177
|
+
raise ValueError(f"[Workflow {wf.key}] 'script' is required for nautobot actions")
|
|
178
|
+
|
|
179
|
+
# Verify the script exists in WorkflowHelper
|
|
180
|
+
if not WorkflowHelper.objects.filter(source_repo=repository_record, file_path=script_path).exists():
|
|
181
|
+
raise ValueError(f"[Workflow {wf.key}] Script not found in repository: {script_path}")
|
|
182
|
+
|
|
183
|
+
WorkflowAction.objects.create(
|
|
184
|
+
workflow=wf,
|
|
185
|
+
phase=phase,
|
|
186
|
+
order=order,
|
|
187
|
+
action_type="nautobot",
|
|
188
|
+
name=act.get("name", "nautobot"),
|
|
189
|
+
when=act.get("when", ""),
|
|
190
|
+
script_path="/" + script_path,
|
|
191
|
+
with_vars=act.get("with", {}),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
elif act.get("type") == "awx":
|
|
195
|
+
WorkflowAction.objects.create(
|
|
196
|
+
workflow=wf,
|
|
197
|
+
phase=phase,
|
|
198
|
+
order=order,
|
|
199
|
+
action_type="awx",
|
|
200
|
+
name=act.get("name", "awx"),
|
|
201
|
+
when=act.get("when", ""),
|
|
202
|
+
awx_template_id=act.get("template_id"),
|
|
203
|
+
awx_extra_vars=act.get("extra_vars", {}),
|
|
204
|
+
monitor=act.get("monitor", True),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
else:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"[Workflow {wf.key}] Unsupported action type in phase '{phase}': {act.get('type')}"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Disable workflows that are no longer in the repository (instead of deleting)
|
|
213
|
+
if delete:
|
|
214
|
+
workflows_to_disable = Workflow.objects.filter(
|
|
215
|
+
source_repo=repository_record,
|
|
216
|
+
enabled=True, # Only disable currently enabled workflows
|
|
217
|
+
).exclude(key__in=synced_workflow_keys)
|
|
218
|
+
|
|
219
|
+
disabled_count = 0
|
|
220
|
+
|
|
221
|
+
for workflow in workflows_to_disable:
|
|
222
|
+
# Count related objects for logging
|
|
223
|
+
action_count = workflow.actions.count()
|
|
224
|
+
input_count = workflow.inputs.count()
|
|
225
|
+
run_count = workflow.runs.count()
|
|
226
|
+
|
|
227
|
+
job_result.log(
|
|
228
|
+
message=f"Disabling workflow '{workflow.name}' (key: {workflow.key}) "
|
|
229
|
+
f"with {action_count} actions, {input_count} inputs, {run_count} runs - no longer in repository"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Disable instead of delete to preserve history
|
|
233
|
+
workflow.enabled = False
|
|
234
|
+
workflow.save()
|
|
235
|
+
disabled_count += 1
|
|
236
|
+
|
|
237
|
+
if disabled_count > 0:
|
|
238
|
+
job_result.log(
|
|
239
|
+
message=f"Disabled {disabled_count} workflows no longer present in repository. Run history preserved."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
clear_repo_script_cache(repository_record)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
datasource_contents = []
|
|
246
|
+
|
|
247
|
+
datasource_contents.append(
|
|
248
|
+
(
|
|
249
|
+
"extras.gitrepository",
|
|
250
|
+
DatasourceContent(
|
|
251
|
+
name="workflows",
|
|
252
|
+
content_identifier="nautobot_workflow_engine.workflows",
|
|
253
|
+
icon="mdi-file-document-outline",
|
|
254
|
+
callback=sync_workflows_from_repo,
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
)
|