aoh

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

commit e6d49badfd6b7a55c3e4d55d426862ed5817da85
parent c579b90a09442fece0a9040dbc2a04ec86f68d73
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Fri, 21 Aug 2026 19:00:55 -0700

Minor cleanup

Diffstat:
Maoh/client.py | 14+++++++++++---
Maoh/connection_plugins/aoh.py | 11++++++-----
Maoh/runner.py | 31+++++++++++++++----------------
Maoh/server.py | 5++---
4 files changed, 34 insertions(+), 27 deletions(-)

diff --git a/aoh/client.py b/aoh/client.py @@ -12,6 +12,8 @@ API = '{{ API_URL }}' # Substitution performed by Flask def exec(args): + """Run a command on the local host.""" + try: p = subprocess.Popen( args, @@ -31,6 +33,8 @@ def exec(args): def put(args): + """Transfer a file to the local host.""" + try: with open(args['dest'], 'wb') as f: f.write(base64.b64decode(args['data'])) @@ -41,6 +45,8 @@ def put(args): def fetch(args): + """Fetch a file from the local host.""" + try: with open(args['dest'], 'rb') as f: return { @@ -51,6 +57,8 @@ def fetch(args): def main(): + """Main execution loop.""" + res = requests.post(f'{API}/runners/', json={ 'host': platform.node() or 'aoh_node', 'playbook': sys.argv[1] if len(sys.argv) >= 2 else 'main', @@ -61,11 +69,11 @@ def main(): sys.exit(1) cookies = res.cookies - job_url = f'{API}{res.headers['Location']}' + runner_url = f'{API}{res.headers['Location']}' req = {} while True: - res = requests.put(job_url, cookies=cookies, json=req).json() + res = requests.put(runner_url, cookies=cookies, json=req).json() if 'logs' in res: print(res['logs'], end='') @@ -74,7 +82,7 @@ def main(): if 'err' in res: print(res['err']) sys.exit(1) - if res['finished']: + if res.get('finished'): break elif 'exec' in res: req = exec(res['exec']) diff --git a/aoh/connection_plugins/aoh.py b/aoh/connection_plugins/aoh.py @@ -1,7 +1,7 @@ # Copyright: (c) 2026, Asher Morgan <asher@ashermorgan.net> # GNU General Public License v3.0+ -DOCUMENTATION = r''' +DOCUMENTATION = """ name: aoh short_description: Run tasks through an Ansible-over-HTTP (AoH) connection @@ -11,7 +11,7 @@ DOCUMENTATION = r''' required: true vars: - name: ansible_aoh_dir -''' +""" import base64 import json @@ -38,6 +38,7 @@ class Connection(ConnectionBase): self.sendbuf = None self.recvbuf = None + def _connect(self) -> Connection: # noqa: F821 """Connect to the host.""" @@ -100,7 +101,7 @@ class Connection(ConnectionBase): def put_file(self, in_path: str, out_path: str) -> None: - """Transfer file to host.""" + """Transfer a file to the host.""" super().put_file(in_path, out_path) @@ -129,7 +130,7 @@ class Connection(ConnectionBase): def fetch_file(self, in_path: str, out_path: str) -> None: - """Fetch file from host.""" + """Fetch a file from the host.""" super().fetch_file(in_path, out_path) @@ -152,7 +153,7 @@ class Connection(ConnectionBase): def close(self) -> None: - """Close connection.""" + """Close connection with host.""" display.vvv('CLOSE CONNECTION FOR AOH RUNNER ' f'{self.get_option('runner')}', diff --git a/aoh/runner.py b/aoh/runner.py @@ -21,14 +21,14 @@ class AoHRunner: def __init__(self, config, playbook, host, args): """Create a new AoH runner.""" - self.id = str(uuid4()) + self.id = str(uuid4()) # Runner ID (set to None after teardown) 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' - self._SENDBUF_PATH = f'{self._dir}/sendbuf' + self._DIR = tempfile.mkdtemp(prefix='aoh-') + self._LOGS_PATH = f'{self._DIR}/artifacts/{self.id}/stdout' + self._RECVBUF_PATH = f'{self._DIR}/recvbuf' + self._SENDBUF_PATH = f'{self._DIR}/sendbuf' self._sendbuf = None self._recvbuf = None self._logs = None @@ -40,7 +40,7 @@ class AoHRunner: try: # Create hostname file so connection plugin can verify hosts - with open(f'{self._dir}/hostname', 'w') as f: + with open(f'{self._DIR}/hostname', 'w') as f: f.write(f'{host}\n') self._start_runner(config, playbook, host, args) @@ -68,15 +68,15 @@ class AoHRunner: """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 + # don't use ansible-runner's inventory directory because that will # shadow user inventory files. - with open(f'{self._dir}/inventory.ini', 'w') as f: + 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, - private_data_dir=self._dir, + private_data_dir=self._DIR, quiet=True, )[0] @@ -95,15 +95,15 @@ class AoHRunner: ), 'ANSIBLE_CONFIG': config, 'ANSIBLE_INVENTORY': ','.join( - [f'{self._dir}/inventory.ini'] + inventory + [f'{self._DIR}/inventory.ini'] + inventory ), } vars = { - 'ansible_aoh_dir': self._dir, + 'ansible_aoh_dir': self._DIR, } self._thread, self._runner = ansible_runner.run_async( - private_data_dir=self._dir, + private_data_dir=self._DIR, ident=self.id, envvars=env, extravars=vars, @@ -111,7 +111,6 @@ class AoHRunner: playbook=playbook, - # verbosity=3, quiet=True, suppress_env_files=True, ) @@ -134,8 +133,6 @@ class AoHRunner: def process_client_request(self, req): """Process an AoH client request.""" - res = {} - with self._lock: if self.id is None: # Runner has already been torn down @@ -144,10 +141,12 @@ class AoHRunner: 'err': 'Client timeout', } + res = {} 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() @@ -206,4 +205,4 @@ class AoHRunner: if self._thread.is_alive(): raise AoHError('Ansible runner thread not terminated') - shutil.rmtree(self._dir) + shutil.rmtree(self._DIR) diff --git a/aoh/server.py b/aoh/server.py @@ -7,7 +7,6 @@ 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__)) @@ -76,7 +75,7 @@ def install(): @app.post('/runners/') -def job_new(): +def new_runner(): if not _validate_args(request.json['args']): return { 'err': 'Bad or banned arguments passed.' }, 400 @@ -97,7 +96,7 @@ def job_new(): @app.put('/runners/<id>') -def job_status(id): +def runner_update(id): if id != session.get('runner'): abort(401)