aoh

A tool for running Ansible playbooks with `curl <url> | python` commands
git clone https://git.ashermorgan.net/aoh/
Log | Files | Refs | README

commit 5d0d78cb521b00af2ca2d76c21cc7038cdc94c48
parent c45bd0b6e346f61b1fdb8e6fcfe030c5cf641832
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Thu, 20 Aug 2026 19:02:23 -0700

Teardown runners ~10m after last client connection

Diffstat:
Maoh/runner.py | 157++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Maoh/server.py | 32+++++++++++++++++++++++++++-----
2 files changed, 126 insertions(+), 63 deletions(-)

diff --git a/aoh/runner.py b/aoh/runner.py @@ -3,19 +3,28 @@ import os import shutil import tempfile import time +from threading import Lock from uuid import uuid4 import ansible_runner _CONNECTION_PLUGIN_DIR = f'{os.path.dirname(__file__)}/connection_plugins/' +_CLIENT_TIMEOUT = 600 # 10 minutes class AoHError(Exception): - """Raised for AoC-specific errors.""" + """Raised for AoH-specific errors.""" class AoHRunner: + """An Ansible-over-HTTP playbook runner.""" + def __init__(self, config, playbook, host, args): + """Create a new AoH runner.""" + self.id = str(uuid4()) + + self._lock = Lock() # Used to protect all public methods + self._dir = tempfile.mkdtemp(prefix='aoh-') self._LOGS_PATH = f'{self._dir}/artifacts/{self.id}/stdout' self._RECVBUF_PATH = f'{self._dir}/recvbuf' @@ -23,21 +32,18 @@ class AoHRunner: self._sendbuf = None self._recvbuf = None self._logs = None + self._thread = None self._runner = None + self._last_req = time.time() + try: # Create hostname file so connection plugin can verify hosts with open(f'{self._dir}/hostname', 'w') as f: f.write(f'{host}\n') - # Set ansible_connection=aoh for the client host only. Note that we - # don't use ansible-runners inventory directory because that will - # shadow user inventory files. - with open(f'{self._dir}/inventory.ini', 'w') as f: - f.write(f'[aoh]\n{host} ansible_connection=aoh') - - self._start_runner(config, playbook, args) + self._start_runner(config, playbook, host, args) # We assume that ansible-runner will eventually create its log file while not os.path.exists(self._LOGS_PATH): @@ -58,7 +64,15 @@ class AoHRunner: raise - def _start_runner(self, config, playbook, args): + def _start_runner(self, config, playbook, host, args): + """Configure and start the underlying ansible-runner.""" + + # Set ansible_connection=aoh for the client host only. Note that we + # don't use ansible-runners inventory directory because that will + # shadow user inventory files. + with open(f'{self._dir}/inventory.ini', 'w') as f: + f.write(f'[aoh]\n{host} ansible_connection=aoh') + raw_config = ansible_runner.get_ansible_config( 'dump', config, @@ -104,65 +118,92 @@ class AoHRunner: def _runner_finished(self): + """Check whether the underlying ansible runner has finished.""" + assert self._runner is not None return self._runner.status not in ['starting', 'running'] + def has_timed_out(self): + """Check whether the AoH runner has timed out.""" + + with self._lock: + return self._last_req < time.time() - _CLIENT_TIMEOUT + + def process_client_request(self, req): + """Process an AoH client request.""" + res = {} - # We check runner status first, in case new logs come in afterwards - res['finished'] = self._runner_finished() - assert self._logs is not None - res['logs'] = self._logs.read() + with self._lock: + if self.id is None: + # Runner has already been torn down + return { + 'finished': True, + 'err': 'Client timeout', + } + + self._last_req = time.time() + + # We check runner status first, in case new logs come in afterwards + res['finished'] = self._runner_finished() + assert self._logs is not None + res['logs'] = self._logs.read() - if req and self._recvbuf: - try: - self._recvbuf.write(json.dumps(req) + '\n') - self._recvbuf.flush() - except BrokenPipeError: - # Ansible should exit soon on its own - pass + if req and self._recvbuf: + try: + self._recvbuf.write(json.dumps(req) + '\n') + self._recvbuf.flush() + except BrokenPipeError: + # Ansible should exit soon on its own + pass - if self._sendbuf: - line = self._sendbuf.readline() - for key, val in json.loads(line or '{}').items(): - res[key] = val + if self._sendbuf: + line = self._sendbuf.readline() + for key, val in json.loads(line or '{}').items(): + res[key] = val - return res + return res def teardown(self): - self.id = None - - if self._logs: - self._logs.close() - - if self._recvbuf: - try: - # Send an error in case the AoH connection plugin is currently - # blocking on a read - self._recvbuf.write('{"err":"Runner teardown"}\n') - self._recvbuf.flush() - except BrokenPipeError: - pass - - try: - self._recvbuf.close() - except BrokenPipeError: - pass - - if self._sendbuf: - try: - self._sendbuf.close() - except BrokenPipeError: - pass - - if self._thread: - # The Ansible runner *should* exit if it hasn't already due to a - # broken recvbuf pipe or the sendbuf error. - self._thread.join(10) - if self._thread.is_alive(): - raise AoHError('Ansible runner thread not terminated') - - shutil.rmtree(self._dir) + """Terminate the AoH runner and free its resources.""" + + with self._lock: + if self.id is None: + # Runner has already been torn down + return + self.id = None + + if self._logs: + self._logs.close() + + if self._recvbuf: + try: + # Send an error in case the AoH connection plugin is currently + # blocking on a read + self._recvbuf.write('{"err":"Runner teardown"}\n') + self._recvbuf.flush() + except BrokenPipeError: + pass + + try: + self._recvbuf.close() + except BrokenPipeError: + pass + + if self._sendbuf: + try: + self._sendbuf.close() + except BrokenPipeError: + pass + + if self._thread: + # The Ansible runner *should* exit if it hasn't already due to a + # broken recvbuf pipe or the sendbuf error. + self._thread.join(10) + if self._thread.is_alive(): + raise AoHError('Ansible runner thread not terminated') + + shutil.rmtree(self._dir) diff --git a/aoh/server.py b/aoh/server.py @@ -2,13 +2,31 @@ import os import yaml from flask import Flask, abort, make_response, render_template, request, session +from flask_apscheduler import APScheduler from .runner import AoHRunner +_RUNNERS = {} +_LAST_GC = 0 +_GC_INTERVAL = 60 # 1 minute + app = Flask(__name__, template_folder=os.path.dirname(__file__)) app.secret_key = os.urandom(16) -_DATA = {} +scheduler = APScheduler() +scheduler.init_app(app) +scheduler.start() + + +@scheduler.task('interval', seconds=_GC_INTERVAL) +def gc(): + """Teardown and delete timed-out runners.""" + + runners = list(_RUNNERS.items()) + for id, runner in runners: + if runner and runner.has_timed_out(): + runner.teardown() + _RUNNERS.pop(id, None) def _get_opts(cmdline): @@ -72,7 +90,7 @@ def job_new(): runner = AoHRunner(playbook['config'], playbook['playbook'], request.json['host'], request.json['args']) - _DATA[runner.id] = runner + _RUNNERS[runner.id] = runner session['runner'] = runner.id return make_response('', 201, {'Location': f'/runners/{runner.id}'}) @@ -83,10 +101,14 @@ def job_status(id): if id != session.get('runner'): abort(401) - res = _DATA[id].process_client_request(request.json) + runner = _RUNNERS.get(id) + if not runner: + abort(400) + + res = runner.process_client_request(request.json) if res['finished']: - _DATA[id].teardown() - del _DATA[id] + runner.teardown() + _RUNNERS.pop(id, None) return res