Skip to content

Commit f89fc64

Browse files
committed
added execution prompts/forms
1 parent c115933 commit f89fc64

File tree

16 files changed

+284
-87
lines changed

16 files changed

+284
-87
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### 0.0.21
66

77
* Added [validation against XSS](https://github.com/ansibleguy/webui/issues/44)
8+
* Execution prompts/forms to provide job overrides
89

910
----
1011

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ See also: [Contributing](https://github.com/ansibleguy/webui/blob/latest/CONTRIB
9494

9595
- [x] Manual/immediate execution
9696

97-
- [ ] Custom Execution-Forms
97+
- [x] Custom Execution-Forms
9898

9999
- [ ] Support for [ad-hoc commands](https://docs.ansible.com/ansible/latest/command_guide/intro_adhoc.html)
100100

docs/source/_static/img/job_exec.png

34 KB
Loading

docs/source/usage/jobs.rst

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
.. _usage_jobs:
2+
3+
.. include:: ../_include/head.rst
4+
5+
.. include:: ../_include/warn_develop.rst
6+
7+
.. |job_exec| image:: ../_static/img/job_exec.png
8+
:class: wiki-img
9+
10+
====
11+
Jobs
12+
====
13+
14+
You can use the UI at :code:`Jobs - Manage` to create and execute jobs.
15+
16+
----
17+
18+
Create
19+
******
20+
21+
To get an overview - Check out the demo at: `demo.webui.ansibleguy.net <https://demo.webui.ansibleguy.net>`_ | Login: User :code:`demo`, Password :code:`Ansible1337`
22+
23+
The job creation form will help you by browsing for playbooks and inventories. For this to work correctly - you should first select the repository to use (*if any is in use*).
24+
25+
You can optionally define a :code:`schedule` in `Cron-format <https://crontab.guru/>`_ to automatically execute the job. Schedule jobs depend on :ref:`Global Credentials <usage_credentials>` (*if any are needed*).
26+
27+
:code:`Credential categories` can be defined if you want to use user-specific credentials to manage your systems. The credentials of the executing user will be dynamically matched if the job is set to :code:`Needs credentials`.
28+
29+
For transparency - the full command that is executed is added on the logs-view.
30+
31+
----
32+
33+
Execute
34+
*******
35+
36+
You have two options to execute a job:
37+
38+
* **Quick execution** - run job as configured without overrides
39+
40+
* **Custom execution** - run job with execution-specific overrides
41+
42+
The fields available as overrides can be configured in the job settings!
43+
44+
You can define required and optional overrides.
45+
46+
|job_exec|
47+
48+
Extra-vars can also be prompted. These need to be supplied in the following format: :code:`var={VAR-NAME}#{DISPLAY-NAME}` per example: :code:`var=add_user#User to add`

src/ansibleguy-webui/aw/api_endpoints/job.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
from re import match as regex_match
2+
13
from django.core.exceptions import ObjectDoesNotExist
24
from django.db.utils import IntegrityError
35
from rest_framework.views import APIView
46
from rest_framework import serializers
57
from rest_framework.response import Response
8+
from rest_framework.exceptions import ValidationError
69
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter
710

811
from aw.config.hardcoded import JOB_EXECUTION_LIMIT
@@ -33,9 +36,30 @@ def validate(self, attrs: dict):
3336
if field in attrs:
3437
validate_no_xss(value=attrs[field], field=field)
3538

39+
for prompt_field in ['execution_prompts_required', 'execution_prompts_optional']:
40+
if is_set(attrs[prompt_field]):
41+
if regex_match(Job.execution_prompts_regex, attrs[prompt_field]) is None:
42+
raise ValidationError('Invalid execution prompt pattern')
43+
44+
translated = []
45+
for field in attrs[prompt_field].split(','):
46+
if field in Job.execution_prompt_aliases:
47+
translated.append(Job.execution_prompt_aliases[field])
48+
49+
else:
50+
translated.append(field)
51+
52+
attrs[prompt_field] = ','.join(translated)
53+
3654
return attrs
3755

3856

57+
class JobExecutionRequest(serializers.ModelSerializer):
58+
class Meta:
59+
model = JobExecution
60+
fields = JobExecution.api_fields_exec
61+
62+
3963
def _find_job(job_id: int) -> (Job, None):
4064
try:
4165
return Job.objects.get(id=job_id)
@@ -285,6 +309,7 @@ def put(self, request, job_id: int):
285309
request=None,
286310
responses={
287311
200: OpenApiResponse(JobReadResponse, description='Job execution queued'),
312+
400: OpenApiResponse(JobReadResponse, description='Bad parameters provided'),
288313
403: OpenApiResponse(JobReadResponse, description='Not privileged to execute the job'),
289314
404: OpenApiResponse(JobReadResponse, description='Job does not exist'),
290315
},
@@ -300,7 +325,23 @@ def post(self, request, job_id: int):
300325
if not has_job_permission(user=user, job=job, permission_needed=CHOICE_PERMISSION_EXECUTE):
301326
return Response(data={'msg': f"Not privileged to execute the job '{job.name}'"}, status=403)
302327

303-
queue_add(job=job, user=user)
328+
if len(request.data) > 0:
329+
serializer = JobExecutionRequest(data=request.data)
330+
if not serializer.is_valid():
331+
return Response(
332+
data={'msg': f"Provided job-execution data is not valid: '{serializer.errors}'"},
333+
status=400,
334+
)
335+
336+
execution = JobExecution(
337+
user=user, job=job, **serializer.validated_data,
338+
)
339+
340+
else:
341+
execution = JobExecution(user=user, job=job, comment='Triggered')
342+
343+
execution.save()
344+
queue_add(execution=execution)
304345
return Response(data={'msg': f"Job '{job.name}' execution queued"}, status=200)
305346

306347
except ObjectDoesNotExist:

src/ansibleguy-webui/aw/api_endpoints/job_util.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Meta:
2626

2727
job_name = serializers.CharField(required=False)
2828
job_comment = serializers.CharField(required=False)
29+
comment = serializers.CharField(required=False)
2930
user_name = serializers.CharField(required=False)
3031
status_name = serializers.CharField(required=False)
3132
failed = serializers.BooleanField(required=False)
@@ -48,6 +49,7 @@ def get_job_execution_serialized(execution: JobExecution) -> dict:
4849
serialized['job'] = execution.job.id
4950
serialized['job_name'] = execution.job.name
5051
serialized['job_comment'] = execution.job.comment
52+
serialized['comment'] = execution.comment
5153
serialized['user'] = execution.user.id if execution.user is not None else None
5254
serialized['user_name'] = execution.user_name
5355
serialized['time_start'] = execution.time_created_str

src/ansibleguy-webui/aw/config/form_metadata.py

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
'credentials_needed': 'Needs Credentials',
1414
'credentials_default': 'Default Job Credentials',
1515
'credentials_category': 'Credentials Category',
16-
'form': 'Execution Form',
16+
'execution_prompts_required': 'Execution Prompts - Required',
17+
'execution_prompts_optional': 'Execution Prompts - Optional',
1718
},
1819
'credentials': {
1920
'connect_user': 'Connect User',
@@ -41,18 +42,6 @@
4142
'git_override_initialize': 'Git Override Initialize-Command',
4243
'git_override_update': 'Git Override Update-Command',
4344
},
44-
'execution_form_field': {
45-
'help': 'Help Text',
46-
'var': 'Variable',
47-
'var_type': 'Variable Type',
48-
'field_type': 'Field Type',
49-
'multiple': 'Multiple Choices',
50-
'separator': 'Multiple-Choice Separator',
51-
'validate_error': 'Validation Error-Message',
52-
'validate_regex': 'Validation Regex',
53-
'validate_max': 'Maximum Value',
54-
'validate_min': 'Minimum Value',
55-
},
5645
},
5746
'settings': {
5847
'permissions': {
@@ -141,7 +130,10 @@
141130
'credentials_category': 'The credential category can be used for dynamic matching of '
142131
'user credentials at execution time',
143132
'enabled': 'En- or disable the schedule. Can be ignored if no schedule was set',
144-
'form': 'Select a Job-Execution form to display on ad-hoc executions',
133+
'execution_prompts_required': 'Required job attributes and/or variables to prompt at custom execution. '
134+
'Comma-separated list of key-value pairs.<br>'
135+
"Variables can be supplied like so: 'var={VAR-NAME}#{DISPLAY-NAME}'<br>"
136+
"Example: 'limit,check,var=add_user#User to add' ",
145137
},
146138
'credentials': {
147139
'vault_file': 'Path to the file containing your vault-password',
@@ -173,18 +165,6 @@
173165
'git_override_update': 'Advanced usage! Completely override the command used to update '
174166
'(pull) the repository',
175167
},
176-
'execution_form_field': {
177-
'var_type': '<b>WARNING</b>: Choose env-var for secret values! Commandline arguments are viewable and '
178-
'will be logged',
179-
'multiple': 'If multiple the form should support multiple user-choices',
180-
'separator': 'If multiple choices are used - they will be joined on serialization before they get passed '
181-
'to the Ansible Execution. This separator will be used on that join operation',
182-
'validate_error': 'Error-Message to display on validation error',
183-
'validate_regex': 'Regex to validate the value. Test it here: '
184-
'<a href="https://regex101.com/">regex101.com</a>. (flavor needs to be JavaScript)',
185-
'validate_max': 'Field-type integer: maximum integer; Field-type string: maximum length',
186-
'validate_min': 'Field-type integer: minimum integer; Field-type string: minimum length',
187-
},
188168
},
189169
'settings': {
190170
'permissions': {
Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
1-
from aw.model.job import Job, JobQueue
1+
from aw.model.job import JobExecution, JobQueue
22
from aw.utils.debug import log
3-
from aw.base import USERS
43

54

6-
def queue_get() -> (tuple[Job, USERS], None):
5+
def queue_get() -> (JobExecution, None):
76
next_queue_item = JobQueue.objects.order_by('-created').first()
87
if next_queue_item is None:
98
return None
109

11-
job, user = next_queue_item.job, next_queue_item.user
10+
execution = next_queue_item.execution
1211
next_queue_item.delete()
13-
return job, user
12+
return execution
1413

1514

16-
def queue_add(job: Job, user: USERS):
17-
log(msg=f"Job '{job.name}' added to execution queue", level=4)
18-
JobQueue(job=job, user=user).save()
15+
def queue_add(execution):
16+
log(msg=f"Job '{execution.job.name} {execution.id}' added to execution queue", level=4)
17+
JobQueue(execution=execution).save()

src/ansibleguy-webui/aw/execute/scheduler.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,16 @@ def status(self):
8585
def check(self):
8686
log('Checking for queued jobs', level=7)
8787
while True:
88-
queue_item = queue_get()
89-
if queue_item is None:
88+
execution = queue_get()
89+
if execution is None:
9090
break
9191

92-
job, user = queue_item
93-
94-
log(f"Adding job-thread for queued job: '{job.name}' (triggered by user '{user.username}')", level=4)
95-
self._add_thread(job=job, execution=JobExecution(user=user, job=job, comment='Triggered'), once=True)
92+
log(
93+
f"Adding job-thread for queued job: '{execution.job.name}' "
94+
f"(triggered by user '{execution.user.username}')",
95+
level=4,
96+
)
97+
self._add_thread(job=execution.job, execution=execution, once=True)
9698

9799
def reload(self, signum=None):
98100
if not self.reloading and not self.stopping:

src/ansibleguy-webui/aw/model/job.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class Job(BaseJob):
8181
'name', 'playbook_file', 'inventory_file', 'repository', 'schedule', 'enabled', 'limit', 'verbosity',
8282
'mode_diff', 'mode_check', 'tags', 'tags_skip', 'verbosity', 'comment', 'environment_vars', 'cmd_args',
8383
'credentials_default', 'credentials_needed', 'credentials_category',
84+
'execution_prompts_required', 'execution_prompts_optional',
8485
]
8586
form_fields_primary = ['name', 'playbook_file', 'inventory_file', 'repository']
8687
form_fields = CHANGE_FIELDS
@@ -104,6 +105,18 @@ class Job(BaseJob):
104105
credentials_category = models.CharField(max_length=100, **DEFAULT_NONE)
105106
repository = models.ForeignKey(Repository, on_delete=models.SET_NULL, related_name='job_fk_repo', **DEFAULT_NONE)
106107

108+
execution_prompts_max_len = 2000
109+
execution_prompts_regex = (r'^(limit|verbosity|comment|mode_diff|diff|mode_check|check|environment_vars|env_vars|'
110+
r'tags|tags_skip|skip_tags|cmd_args|var=[^,#]*?|var=[^#]*?#[^,]*?|[,$])+$')
111+
execution_prompt_aliases = {
112+
'check': 'mode_check',
113+
'diff': 'mode_diff',
114+
'env_vars': 'environment_vars',
115+
'skip_tags': 'tags_skip',
116+
}
117+
execution_prompts_required = models.CharField(max_length=execution_prompts_max_len, **DEFAULT_NONE)
118+
execution_prompts_optional = models.CharField(max_length=execution_prompts_max_len, **DEFAULT_NONE)
119+
107120
def __str__(self) -> str:
108121
limit = '' if self.limit is None else f' [{self.limit}]'
109122
return f"Job '{self.name}' ({self.playbook_file} => {self.inventory_file}{limit})"
@@ -194,9 +207,13 @@ class JobExecution(BaseJob):
194207
api_fields_read = [
195208
'id', 'job', 'job_name', 'user', 'user_name', 'result', 'status', 'status_name', 'time_start', 'time_fin',
196209
'failed', 'error_s', 'error_m', 'log_stdout', 'log_stdout_url', 'log_stderr', 'log_stderr_url', 'job_comment',
197-
'credential_global', 'credential_user', 'command', 'log_stdout_repo', 'log_stderr_repo',
210+
'comment', 'credential_global', 'credential_user', 'command', 'log_stdout_repo', 'log_stderr_repo',
198211
'log_stdout_repo_url', 'log_stderr_repo_url',
199212
]
213+
api_fields_exec = [
214+
'comment', 'limit', 'verbosity', 'mode_diff', 'mode_check', 'environment_vars', 'tags', 'tags_skip',
215+
'cmd_args',
216+
]
200217
log_file_fields = ['log_stdout', 'log_stderr', 'log_stdout_repo', 'log_stderr_repo']
201218

202219
# NOTE: scheduled execution will have no user
@@ -275,8 +292,6 @@ def user_name(self) -> str:
275292

276293

277294
class JobQueue(BareModel):
278-
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='jobqueue_fk_job')
279-
user = models.ForeignKey(
280-
USERS, on_delete=models.SET_NULL, null=True,
281-
related_name='jobqueue_fk_user',
295+
execution = models.ForeignKey(
296+
JobExecution, on_delete=models.CASCADE, related_name='jobqueue_fk_jobexec', **DEFAULT_NONE,
282297
)

src/ansibleguy-webui/aw/static/css/aw.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,10 @@ input:invalid {
391391
border-color: red;
392392
}
393393

394+
.aw-exec-form {
395+
overflow: hidden;
396+
}
397+
394398
.mb-3 {
395399
margin-bottom: 0.1rem !important;
396400
}

src/ansibleguy-webui/aw/static/css/aw_mobile.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
}
99

1010
form .form-control, .aw-fs-choices {
11-
width: 35vw;
11+
width: 45vw;
1212
}
1313

1414
.aw-responsive-lg {
@@ -50,7 +50,7 @@
5050
}
5151

5252
form .form-control, .aw-fs-choices {
53-
width: 50vw;
53+
width: 60vw;
5454
}
5555

5656
.aw-responsive-lg {
@@ -78,7 +78,7 @@
7878
}
7979

8080
form .form-control, .aw-login, .aw-login-fields, .aw-fs-choices {
81-
width: 70vw;
81+
width: 90vw;
8282
}
8383

8484
.aw-btn-action-icon {

src/ansibleguy-webui/aw/static/js/aw.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ function escapeHTML(data) {
225225
return data;
226226
}
227227

228+
function capitalizeFirstLetter(string) {
229+
return string.charAt(0).toUpperCase() + string.slice(1);
230+
}
231+
228232
// API CALLS
229233
const CSRF_TOKEN = getCookie('csrftoken');
230234

0 commit comments

Comments
 (0)