diff --git a/MANIFEST.in b/MANIFEST.in index ff6fec4..dd599e5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -recursive-include ansible-webui/* \ No newline at end of file +recursive-include ansible-webui/* +include requirements.txt \ No newline at end of file diff --git a/README.md b/README.md index eb4be7c..d158bc0 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![Lint](https://github.com/ansibleguy/ansible-webui/actions/workflows/lint.yml/badge.svg?branch=latest)](https://github.com/ansibleguy/ansible-webui/actions/workflows/lint.yml) [![Test](https://github.com/ansibleguy/ansible-webui/actions/workflows/test.yml/badge.svg?branch=latest)](https://github.com/ansibleguy/ansible-webui/actions/workflows/test.yml) -This project was inspired by [ansible-semaphore](https://github.com/ansible-semaphore/semaphore). -The goal is to allow users to quickly install a WebUI for using Ansible locally. + +The goal is to allow users to quickly install & run a WebUI for using Ansible locally. This is achived by [distributing it using pip](https://pypi.org/project/ansible-webui/). @@ -43,6 +43,45 @@ Feel free to contribute to this project using [pull-requests](https://github.com See also: [Contributing](https://github.com/ansibleguy/ansible-webui/blob/latest/CONTRIBUTE.md) +---- + +## Placement + +There are multiple Ansible WebUI products - how do they compare to this product? + +* **[Ansible AWX](https://www.ansible.com/community/awx-project) / [Ansible Automation Platform](https://www.redhat.com/en/technologies/management/ansible/pricing)** + + If you want an enterprise-grade solution - you might want to use these official products. + + They have many neat features and are designed to run in containerized & scalable environments. + + The actual enterprise solution named 'Ansible Automation Platform' can be pretty expensive. + + +* **[Ansible Semaphore](https://github.com/ansible-semaphore/semaphore)** + + Semaphore is a pretty lightweight WebUI for Ansible. + + It is a single binary and built from Golang (backend) and Node.js/Vue.js (frontend). + + Ansible job [execution is done using shell](https://github.com/ansible-semaphore/semaphore/blob/develop/db_lib/AnsiblePlaybook.go#L57). + + The project is [managed by a single maintainer and has some issues](https://github.com/ansible-semaphore/semaphore/discussions/1111). + + The 'Ansible-WebUI' project was inspired by Semaphore. + + +* **This project** + + It is built to be very lightweight. + + As Ansible already requires Python3 - I chose it as primary language. + + The backend stack is built of [gunicorn](https://gunicorn.org/)/[Django](https://www.djangoproject.com/) and the frontend consists of Django templates and vanilla JS. + + Ansible job execution is done using the native [Python API](https://ansible.readthedocs.io/projects/runner/en/latest/python_interface/)! + + Target users are small to medium businesses and Ansible users that just want a UI to run their playbooks. ---- @@ -58,13 +97,13 @@ See also: [Contributing](https://github.com/ansibleguy/ansible-webui/blob/latest - [ ] Management interface (Django built-in) - - [ ] Groups & Permissions + - [ ] Groups & Job Permissions - [ ] [LDAP integration](https://github.com/django-auth-ldap/django-auth-ldap) - [ ] Jobs - - [ ] Execute Ansible using its [Python API](https://docs.ansible.com/ansible/latest/dev_guide/developing_api.html) + - [ ] Execute Ansible using its [Python API](https://ansible.readthedocs.io/projects/runner/en/latest/python_interface/) - [ ] Ad-Hoc execution @@ -81,6 +120,10 @@ See also: [Contributing](https://github.com/ansibleguy/ansible-webui/blob/latest - [ ] WebUI + - [ ] Job Dashboard + + Status, Execute, Time of last & next execution, Last run User, Links to Warnings/Errors + - [ ] Show Ansible Running-Config - [ ] Show Ansible Collections diff --git a/ansible-webui/aw/config/environment.py b/ansible-webui/aw/config/environment.py new file mode 100644 index 0000000..8717432 --- /dev/null +++ b/ansible-webui/aw/config/environment.py @@ -0,0 +1,31 @@ +from os import environ, getcwd +from pathlib import Path +from secrets import choice as random_choice +from string import digits, ascii_letters, punctuation +from datetime import datetime + + +def get_existing_ansible_config_file() -> str: + # https://docs.ansible.com/ansible/latest/reference_appendices/config.html#the-configuration-file + + for file in [ + getcwd() + '/ansible.cfg', + environ['HOME'] + '/ansible.cfg', + environ['HOME'] + '/.ansible.cfg', + ]: + if Path(file).is_file(): + return file + + return '/etc/ansible/ansible.cfg' + + +ENVIRON_FALLBACK = { + 'timezone': {'keys': ['AW_TIMEZONE', 'TZ'], 'fallback': datetime.now().astimezone().tzname()}, + '_secret': { + 'keys': ['AW_SECRET'], + 'fallback': ''.join(random_choice(ascii_letters + digits + punctuation) for _ in range(50)) + }, + 'path_base': {'keys': ['AW_PATH_BASE'], 'fallback': '/tmp/ansible-webui'}, + 'path_play': {'keys': ['AW_PATH_PLAY', 'ANSIBLE_PLAYBOOK_DIR'], 'fallback': getcwd()}, + 'ansible_config': {'keys': ['ANSIBLE_CONFIG'], 'fallback': get_existing_ansible_config_file()} +} diff --git a/ansible-webui/aw/config/hardcoded.py b/ansible-webui/aw/config/hardcoded.py index 9b919d8..5daa823 100644 --- a/ansible-webui/aw/config/hardcoded.py +++ b/ansible-webui/aw/config/hardcoded.py @@ -9,6 +9,8 @@ RELOAD_INTERVAL = 10 LOGIN_PATH = '/a/login/' LOGOUT_PATH = '/o/' +LOG_TIME_FORMAT = '%Y-%m-%d %H:%M:%S %z' +RUNNER_TMP_DIR_TIME_FORMAT = '%Y-%m-%d_%H-%M-%S' PERMISSIONS = dict( access='AW_ACCESS', diff --git a/ansible-webui/aw/config/main.py b/ansible-webui/aw/config/main.py index 2d51298..e3b323a 100644 --- a/ansible-webui/aw/config/main.py +++ b/ansible-webui/aw/config/main.py @@ -1,17 +1,8 @@ from os import environ -from secrets import choice as random_choice -from string import digits, ascii_letters, punctuation -from datetime import datetime -from pytz import all_timezones +from pytz import all_timezones -ENVIRON_FALLBACK = { - 'timezone': {'keys': ['AW_TIMEZONE', 'TZ'], 'fallback': datetime.now().astimezone().tzname()}, - '_secret': { - 'keys': ['AW_SECRET'], - 'fallback': ''.join(random_choice(ascii_letters + digits + punctuation) for i in range(50)) - }, -} +from aw.config.environment import ENVIRON_FALLBACK def init_globals(): diff --git a/ansible-webui/aw/execute/play.py b/ansible-webui/aw/execute/play.py index 4bdf172..4738fef 100644 --- a/ansible-webui/aw/execute/play.py +++ b/ansible-webui/aw/execute/play.py @@ -1,12 +1,20 @@ -# https://docs.ansible.com/ansible/latest/dev_guide/developing_api.html -# https://github.com/ansible/ansible/blob/devel/lib/ansible/cli/playbook.py -# https://github.com/ansible/ansible/blob/devel/lib/ansible/cli/__init__.py -# https://github.com/ansible/ansible/blob/devel/lib/ansible/executor/playbook_executor.py +from datetime import datetime +from ansible_runner import run as ansible_run -# from ansible.executor.playbook_executor import PlaybookExecutor +from aw.model.job import Job, JobExecution +from aw.execute.util import runner_cleanup, runner_prep, parse_run_result -from aw.config.main import config +def ansible_playbook(job: Job, execution: JobExecution): + time_start = datetime.now() + opts = runner_prep(job=job, execution=execution) -def ansible_playbook(job): - pass + result = ansible_run(**opts) + + parse_run_result( + time_start=time_start, + execution=execution, + result=result, + ) + + runner_cleanup(opts) diff --git a/ansible-webui/aw/execute/util.py b/ansible-webui/aw/execute/util.py new file mode 100644 index 0000000..7c1684b --- /dev/null +++ b/ansible-webui/aw/execute/util.py @@ -0,0 +1,142 @@ +from os import chmod +from pathlib import Path +from shutil import rmtree +from random import choice as random_choice +from string import digits +from datetime import datetime + +from ansible_runner import Runner as AnsibleRunner + +from aw.config.main import config +from aw.config.hardcoded import RUNNER_TMP_DIR_TIME_FORMAT +from aw.utils.util import get_choice_key_by_value +from aw.utils.handlers import AnsibleConfigError +from aw.model.job import Job, JobExecution, JobExecutionResult, JobExecutionResultHost, CHOICES_JOB_EXEC_STATUS + + +def _decode_env_vars(env_vars_csv: str, src: str) -> dict: + try: + env_vars = {} + for kv in env_vars_csv.split(','): + k, v = kv.split('=') + env_vars[k] = v + + return env_vars + + except ValueError: + raise AnsibleConfigError( + f"Environmental variables of {src} are not in a valid format " + f"(comma-separated key-value pairs). Example: 'key1=val1,key2=val2'" + ) + + +def _update_execution_status(execution: JobExecution, status: str): + execution.status = get_choice_key_by_value(choices=CHOICES_JOB_EXEC_STATUS, value=status) + execution.save() + + +def _runner_options(job: Job, execution: JobExecution) -> dict: + # NOTES: + # playbook str or list + # project_dir = playbook_dir + # quiet + # limit, verbosity, envvars + + # build unique temporary execution directory + path_base = config['path_base'] + if not path_base.endswith('/'): + path_base += '/' + + path_base += datetime.now().strftime(RUNNER_TMP_DIR_TIME_FORMAT) + path_base += ''.join(random_choice(digits) for _ in range(5)) + + # merge job + execution env-vars + env_vars = {} + if job.environment_vars is not None: + env_vars = { + **env_vars, + **_decode_env_vars(env_vars_csv=job.environment_vars, src='Job') + } + + if execution.environment_vars is not None: + env_vars = { + **env_vars, + **_decode_env_vars(env_vars_csv=execution.environment_vars, src='Execution') + } + + return { + 'runner_mode': 'pexpect', + 'private_data_dir': path_base, + 'project_dir': config['path_play'], + 'quiet': True, + 'limit': execution.limit if execution.limit is not None else job.limit, + 'envvars': env_vars, + } + + +def runner_prep(job: Job, execution: JobExecution): + _update_execution_status(execution, status='Starting') + + opts = _runner_options(job=job, execution=execution) + opts['playbook'] = job.playbook.split(',') + opts['inventory'] = job.inventory.split(',') + + # https://docs.ansible.com/ansible/2.8/user_guide/playbooks_best_practices.html#directory-layout + project_dir = opts['project_dir'] + if not project_dir.endswith('/'): + project_dir += '/' + + for playbook in opts['playbook']: + ppf = project_dir + playbook + if not Path(ppf).is_file(): + raise AnsibleConfigError(f"Configured playbook not found: '{ppf}'") + + for inventory in opts['inventory']: + pi = project_dir + inventory + if not Path(pi).exists(): + raise AnsibleConfigError(f"Configured inventory not found: '{pi}'") + + pdd = Path(opts['private_data_dir']) + if not pdd.is_dir(): + pdd.mkdir() + chmod(path=pdd, mode=0o750) + + _update_execution_status(execution, status='Running') + + +def runner_cleanup(opts: dict): + rmtree(opts['private_data_dir']) + + +def parse_run_result(execution: JobExecution, time_start: datetime, result: AnsibleRunner): + job_result = JobExecutionResult( + time_start=time_start, + failed=result.errored, + ) + job_result.save() + + # https://stackoverflow.com/questions/70348314/get-python-ansible-runner-module-stdout-key-value + for host in result.stats['processed']: + result_host = JobExecutionResultHost() + + result_host.unreachable = host in result.stats['unreachable'] + result_host.tasks_skipped = result.stats['skipped'][host] if host in result.stats['skipped'] else 0 + result_host.tasks_ok = result.stats['ok'][host] if host in result.stats['ok'] else 0 + result_host.tasks_failed = result.stats['failures'][host] if host in result.stats['failures'] else 0 + result_host.tasks_ignored = result.stats['ignored'][host] if host in result.stats['ignored'] else 0 + result_host.tasks_rescued = result.stats['rescued'][host] if host in result.stats['rescued'] else 0 + result_host.tasks_changed = result.stats['changed'][host] if host in result.stats['changed'] else 0 + + if result_host.tasks_failed > 0: + # todo: create errors + pass + + result_host.result = job_result + result_host.save() + + execution.result = job_result + if job_result.failed: + _update_execution_status(execution, status='Failed') + + else: + _update_execution_status(execution, status='Finished') diff --git a/ansible-webui/aw/model/base.py b/ansible-webui/aw/model/base.py index 291ff25..0acb87c 100644 --- a/ansible-webui/aw/model/base.py +++ b/ansible-webui/aw/model/base.py @@ -1,6 +1,6 @@ from django.db import models -BOOLEAN_CHOICES = ( +CHOICES_BOOL = ( (True, 'Yes'), (False, 'No') ) diff --git a/ansible-webui/aw/model/job.py b/ansible-webui/aw/model/job.py index d9f0921..14a4006 100644 --- a/ansible-webui/aw/model/job.py +++ b/ansible-webui/aw/model/job.py @@ -4,12 +4,10 @@ from crontab import CronTab -from aw.model.base import BareModel, BaseModel +from aw.model.base import BareModel, BaseModel, CHOICES_BOOL class JobError(BareModel): - field_list = ['short', 'med', 'logfile'] - short = models.CharField(max_length=100) med = models.TextField(max_length=1024, null=True) logfile = models.FilePathField() @@ -18,14 +16,7 @@ def __str__(self) -> str: return f"Job error {self.created}: '{self.short}'" -class JobWarning(JobError): - def __str__(self) -> str: - return f"Job warning {self.created}: '{self.short}'" - - class JobPermission(BaseModel): - field_list = ['name', 'permission', 'users', 'groups'] - name = models.CharField(max_length=100) permission = models.CharField( max_length=50, @@ -56,13 +47,33 @@ class JobPermissionMemberGroup(BareModel): permission = models.ForeignKey(JobPermission, on_delete=models.CASCADE) -class Job(BaseModel): - field_list = ['job_id', 'name', 'inventory', 'playbook', 'schedule', 'permission'] +CHOICES_JOB_VERBOSITY = ( + (0, 'None'), + (1, 'v'), + (2, 'vv'), + (3, 'vvv'), + (4, 'vvvv'), + (5, 'vvvv'), + (6, 'vvvvvv'), +) + + +class MetaJob(BaseModel): + limit = models.CharField(max_length=500, null=True, default=None) + verbosity = models.PositiveSmallIntegerField(choices=CHOICES_JOB_VERBOSITY, default=0) + # NOTE: one or multiple comma-separated vars + environment_vars = models.CharField(max_length=1000, null=True, default=None) + + class Meta: + abstract = True + + +class Job(MetaJob): job_id = models.PositiveIntegerField(primary_key=True) name = models.CharField(max_length=150) - inventory = models.CharField(max_length=150) - playbook = models.CharField(max_length=150) + inventory = models.CharField(max_length=300) # NOTE: one or multiple comma-separated inventories + playbook = models.CharField(max_length=300) # NOTE: one or multiple comma-separated playbooks schedule = models.CharField(max_length=50, validators=[CronTab]) permission = models.ForeignKey(JobPermission, on_delete=models.SET_NULL, null=True) @@ -70,33 +81,59 @@ def __str__(self) -> str: return f"Job '{self.name}' ('{self.playbook}')" -class JobExecution(BareModel): - field_list = [ - 'user', 'start', 'fin', 'error', - 'result_ok', 'result_changed', 'result_unreachable', 'result_failed', 'result_skipped', - 'result_rescued', 'result_ignored', - ] +class JobExecutionResult(BareModel): + # ansible_runner.runner.Runner + time_start = models.DateTimeField(auto_now_add=True) + time_fin = models.DateTimeField(blank=True, null=True, default=None) + + failed = models.BooleanField(choices=CHOICES_BOOL, default=False) + + +class JobExecutionResultHost(BareModel): + # ansible_runner.runner.Runner.stats + hostname = models.CharField(max_length=300) + unreachable = models.BooleanField(choices=CHOICES_BOOL, default=False) + tasks_skipped = models.PositiveSmallIntegerField(default=0) + tasks_ok = models.PositiveSmallIntegerField(default=0) + tasks_failed = models.PositiveSmallIntegerField(default=0) + tasks_rescued = models.PositiveSmallIntegerField(default=0) + tasks_ignored = models.PositiveSmallIntegerField(default=0) + tasks_changed = models.PositiveSmallIntegerField(default=0) + + error = models.ForeignKey(JobError, on_delete=models.CASCADE, related_name=f"jobresulthost_fk_error") + result = models.ForeignKey(JobExecutionResult, on_delete=models.CASCADE, related_name=f"jobresulthost_fk_result") + + def __str__(self) -> str: + if int(self.tasks_failed) > 0: + result = 'failed' + else: + result = 'success' if self.warning is None else 'warning' + + return f"Job execution {self.created} of host {self.hostname}: {result}" + + +CHOICES_JOB_EXEC_STATUS = [ + (0, 'Waiting'), + (1, 'Starting'), + (2, 'Running'), + (3, 'Failed'), + (4, 'Finished'), +] + + +class JobExecution(MetaJob): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, blank=True, null=True, related_name=f"jobexec_fk_user" ) job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name=f"jobexec_fk_job") - start = models.DateTimeField(auto_now_add=True) - fin = models.DateTimeField(blank=True, null=True, default=None) - error = models.ForeignKey(JobError, on_delete=models.CASCADE, related_name=f"jobexec_fk_error") - warning = models.ForeignKey(JobWarning, on_delete=models.CASCADE, related_name=f"jobexec_fk_warning") - result_ok = models.PositiveSmallIntegerField(default=0) - result_changed = models.PositiveSmallIntegerField(default=0) - result_unreachable = models.PositiveSmallIntegerField(default=0) - result_failed = models.PositiveSmallIntegerField(default=0) - result_skipped = models.PositiveSmallIntegerField(default=0) - result_rescued = models.PositiveSmallIntegerField(default=0) - result_ignored = models.PositiveSmallIntegerField(default=0) + result = models.ForeignKey( + JobExecutionResult, on_delete=models.CASCADE, related_name=f"jobexec_fk_result", + null=True, default=None, # execution is created before result is available + ) + status = models.PositiveSmallIntegerField(default=0, choices=CHOICES_JOB_EXEC_STATUS) def __str__(self) -> str: - result = 'success' if self.warning is None else 'warning' - if self.error is not None: - result = 'failed' - - return f"Job '{self.job.name}' execution {self.created}: {result}" + status_name = CHOICES_JOB_EXEC_STATUS[int(self.status)][1] + return f"Job '{self.job.name}' execution {self.created}: {status_name}" diff --git a/ansible-webui/aw/models.py b/ansible-webui/aw/models.py index 8d6c6f5..fac465c 100644 --- a/ansible-webui/aw/models.py +++ b/ansible-webui/aw/models.py @@ -1 +1,2 @@ -from aw.model.job import JobError, JobExecution, Job, JobPermission, JobPermissionMemberGroup, JobPermissionMemberUser +from aw.model.job import JobError, JobExecution, Job, JobPermission, JobPermissionMemberGroup, \ + JobPermissionMemberUser, JobExecutionResult diff --git a/ansible-webui/aw/utils/debug.py b/ansible-webui/aw/utils/debug.py index a555d04..5368826 100644 --- a/ansible-webui/aw/utils/debug.py +++ b/ansible-webui/aw/utils/debug.py @@ -1,7 +1,8 @@ -from os import environ, getpid +from os import getpid from aw.utils.util import datetime_w_tz from aw.utils.deployment import deployment_dev +from aw.config.hardcoded import LOG_TIME_FORMAT PID = getpid() @@ -15,8 +16,6 @@ 7: 'DEBUG', } -LOG_TIME_FORMAT = '%Y-%m-%d %H:%M:%S %z' - def log(msg: str, level: int = 3): if level > 5 and not deployment_dev(): diff --git a/ansible-webui/aw/utils/handlers.py b/ansible-webui/aw/utils/handlers.py index 64dff77..8fb9bbb 100644 --- a/ansible-webui/aw/utils/handlers.py +++ b/ansible-webui/aw/utils/handlers.py @@ -3,6 +3,10 @@ from utils.debug import log +class AnsibleConfigError(Exception): + pass + + def handler_log(request, msg: str, status: int): log(f"{request.build_absolute_uri()} - Got error {status} - {msg}") diff --git a/ansible-webui/aw/utils/util.py b/ansible-webui/aw/utils/util.py index 594037b..1ba9dee 100644 --- a/ansible-webui/aw/utils/util.py +++ b/ansible-webui/aw/utils/util.py @@ -7,3 +7,9 @@ def datetime_w_tz() -> datetime: return timezone(config['timezone']).localize(datetime.now()) + + +def get_choice_key_by_value(choices: list[tuple], value): + for k, v in choices: + if v == value: + return k diff --git a/ansible-webui/base/webserver.py b/ansible-webui/base/webserver.py index b2dad51..a449501 100644 --- a/ansible-webui/base/webserver.py +++ b/ansible-webui/base/webserver.py @@ -38,7 +38,7 @@ def load_config(self): def create_webserver() -> WSGIApplication: - gunicorn.SERVER = ''.join(random_choice(ascii_letters) for i in range(10)) + gunicorn.SERVER = ''.join(random_choice(ascii_letters) for _ in range(10)) run_options = { 'workers': (cpu_count() * 2) + 1, **OPTIONS_PROD diff --git a/docs/source/usage/2_config.rst b/docs/source/usage/2_config.rst index 1cff85f..8642674 100644 --- a/docs/source/usage/2_config.rst +++ b/docs/source/usage/2_config.rst @@ -6,7 +6,7 @@ 2 - Config ========== -Most configuration can be managed using the WebUI: :code:`https:///m/` (*Django Admin interface*) +Most configuration can be managed using the WebUI. Environmental variables *********************** diff --git a/requirements.txt b/requirements.txt index b90b2c9..8e7dfb5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,4 @@ fontawesomefree crontab # ansible -ansible-core +ansible-runner diff --git a/scripts/build.sh b/scripts/build.sh index 20c05d3..27cffb4 100644 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -5,5 +5,6 @@ set -e cd "$(dirname "$0")/.." rm -rf dist/* python3 -m build -python3 -m twine upload --repository pypi dist/* + +#python3 -m twine upload --repository pypi dist/* diff --git a/setup.py b/setup.py index efc3ff5..3958585 100644 --- a/setup.py +++ b/setup.py @@ -3,6 +3,9 @@ with open('README.md', 'r', encoding='utf-8') as info: long_description = info.read() +with open('requirements.txt', 'r', encoding='utf-8') as reqs: + requirements = [req for req in reqs.readlines() if not req.startswith('#') or req.strip() == ''] + setuptools.setup( name='ansible-webui', version='0.0.1', @@ -21,5 +24,6 @@ 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'Operating System :: OS Independent', ], - python_requires='>=3.5' + python_requires='>=3.5', + install_requires=requirements, )