Skip to content

Commit

Permalink
basic ansible-runner integration
Browse files Browse the repository at this point in the history
  • Loading branch information
ansibleguy committed Jan 16, 2024
1 parent ee33344 commit 3af5284
Show file tree
Hide file tree
Showing 18 changed files with 340 additions and 70 deletions.
3 changes: 2 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
recursive-include ansible-webui/*
recursive-include ansible-webui/*
include requirements.txt
51 changes: 47 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

Expand Down Expand Up @@ -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.

----

Expand All @@ -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

Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions ansible-webui/aw/config/environment.py
Original file line number Diff line number Diff line change
@@ -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()}
}
2 changes: 2 additions & 0 deletions ansible-webui/aw/config/hardcoded.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 2 additions & 11 deletions ansible-webui/aw/config/main.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down
24 changes: 16 additions & 8 deletions ansible-webui/aw/execute/play.py
Original file line number Diff line number Diff line change
@@ -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)
142 changes: 142 additions & 0 deletions ansible-webui/aw/execute/util.py
Original file line number Diff line number Diff line change
@@ -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')
2 changes: 1 addition & 1 deletion ansible-webui/aw/model/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.db import models

BOOLEAN_CHOICES = (
CHOICES_BOOL = (
(True, 'Yes'),
(False, 'No')
)
Expand Down
Loading

0 comments on commit 3af5284

Please sign in to comment.