aoh

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

commit e7830972547b44a7a1e6d265721ecf6a9fae0339
parent 8922f9ece9ce7a8209639592454458bb3d3d858d
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Sat, 15 Aug 2026 14:26:22 -0700

Extremely barebones COMMAND/PUT/FETCH execution

Diffstat:
Mconnection_plugins/http.py | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Minstall.py | 44++++++++++++++++++++++++++++++++++++++------
Mmain.py | 38++++++++++++++++++++++++++++++--------
3 files changed, 124 insertions(+), 18 deletions(-)

diff --git a/connection_plugins/http.py b/connection_plugins/http.py @@ -9,15 +9,18 @@ DOCUMENTATION = r''' options: runner: - description: The UUID of the associated Ansible runner + description: The artifact directory of the associated Ansible runner required: true vars: - name: ansible_http_runner ''' # ruff: disable[E402] +import base64 +import json import typing as t +from ansible.module_utils.common.text.converters import to_text from ansible.plugins.connection import ConnectionBase from ansible.utils.display import Display # ruff: enable[E402] @@ -35,6 +38,8 @@ class Connection(ConnectionBase): def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: super(Connection, self).__init__(*args, **kwargs) + self.sendbuf = None + self.recvbuf = None def _connect(self) -> Connection: # noqa: F821 """Connect to the host (nop).""" @@ -43,6 +48,9 @@ class Connection(ConnectionBase): display.vvv('ESTABLISH HTTP CONNECTION FOR RUNNER ' f'{self.get_option('runner')}', host=self._play_context.remote_addr) + + self.sendbuf = open(f'{self.get_option('runner')}/sendbuf', 'w') + self.recvbuf = open(f'{self.get_option('runner')}/recvbuf', 'r') self._connected = True return self @@ -56,7 +64,23 @@ class Connection(ConnectionBase): display.vvv(f'EXEC {cmd}', host=self._play_context.remote_addr) - return (0, b'', b'') + assert self.sendbuf is not None + assert self.recvbuf is not None + assert isinstance(cmd, str) + assert in_data is None + # assert sudoable is False + + self.sendbuf.write(json.dumps({ + 'command': cmd, + }) + '\n') + self.sendbuf.flush() + + res = json.loads(self.recvbuf.readline()) + return ( + res['returncode'], + res['stdout'].encode('latin1'), + res['stderr'].encode('latin1'), + ) def put_file(self, in_path: str, out_path: str) -> None: @@ -67,6 +91,22 @@ class Connection(ConnectionBase): display.vvv(f'PUT {in_path} TO {out_path}', host=self._play_context.remote_addr) + assert self.sendbuf is not None + assert self.recvbuf is not None + + with open(in_path, 'rb') as f: + data = base64.b64encode(f.read()).decode() + + self.sendbuf.write(json.dumps({ + 'put': { + 'data': data, + 'dest': out_path, + }, + }) + '\n') + self.sendbuf.flush() + + self.recvbuf.readline() + def fetch_file(self, in_path: str, out_path: str) -> None: """Fetch file from host (TODO).""" @@ -76,13 +116,25 @@ class Connection(ConnectionBase): display.vvv(f'FETCH {in_path} TO {out_path}', host=self._play_context.remote_addr) - with open(out_path, 'wb') as f: - f.write(b'') + assert self.sendbuf is not None + assert self.recvbuf is not None + + self.sendbuf.write(json.dumps({ + 'get': in_path, + }) + '\n') + self.sendbuf.flush() + + res = json.loads(self.recvbuf.readline()) + with open(out_path, 'rb') as f: + f.write(base64.b64decode(res['data'])) def close(self) -> None: """Close connection.""" self._connected = False + display.vvv('CLOSE HTTP CONNECTION FOR RUNNER ' + f'{self.get_option('runner')}', + host=self._play_context.remote_addr) super(Connection, self).close() diff --git a/install.py b/install.py @@ -1,22 +1,54 @@ #!/usr/bin/env python3 -import requests # TODO: eliminate dependency? +import base64 +import subprocess import time +import requests # TODO: eliminate dependency? + API = 'http://localhost:5000' res = requests.post(f'{API}/runners/', json={ 'host': 'localhost', }) +cookies = res.cookies assert res.status_code == 201 job_url = f'{API}{res.headers['Location']}' +req = {} while True: - data = requests.get(job_url, cookies=res.cookies).json() - print(f'Status: {data['status']}') - - if data['status'] not in ['started', 'running']: + res = requests.put(job_url, cookies=cookies, json=req).json() + if res['status'] not in ['started', 'running']: break - time.sleep(1) + + req = {} + if (command := res.get('command')) is not None: + print('EXEC', command) + p = subprocess.Popen( + command, + shell=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout, stderr = p.communicate() + req['returncode'] = p.returncode + req['stdout'] = stdout.decode('latin1') + req['stderr'] = stderr.decode('latin1') + print('EXEC RETURN', p.returncode) + + elif (put := res.get('put')) is not None: + print('PUT', put['dest'], len(put['data']), 'bytes') + with open(put['dest'], 'wb') as f: + f.write(base64.b64decode(put['data'])) + req['ok'] = True + + elif (get := res.get('get')) is not None: + print('PUT', put['dest'], len(put['data']), 'bytes') + with open(put['dest'], 'rb') as f: + req['data'] = base64.b64encode(f.read()).decode() + + else: + time.sleep(0.1) diff --git a/main.py b/main.py @@ -1,3 +1,4 @@ +import json import os import ansible_runner @@ -7,6 +8,7 @@ from uuid import uuid4 DATA = {} PLAYBOOK = os.path.abspath('./demo/playbook.yml') # TODO +PRIVATE_DIR = os.path.abspath('./private/') # TODO app = Flask(__name__) app.secret_key = os.urandom(16) # TODO @@ -27,7 +29,7 @@ def job_new(): 'hosts': { request.json['host']: { 'ansible_connection': 'http', - 'ansible_http_runner': id, + 'ansible_http_runner': f'{PRIVATE_DIR}/artifacts/{id}', }, }, }, @@ -40,28 +42,48 @@ def job_new(): '/usr/share/ansible/plugins/connection', ]) } - _, r = ansible_runner.run_async(private_data_dir='private', + os.makedirs(f'{PRIVATE_DIR}/artifacts/{id}/') + os.mkfifo(f'{PRIVATE_DIR}/artifacts/{id}/sendbuf') + os.mkfifo(f'{PRIVATE_DIR}/artifacts/{id}/recvbuf') + _, r = ansible_runner.run_async(private_data_dir=PRIVATE_DIR, limit=request.json['host'], inventory=inv, envvars=env, playbook=PLAYBOOK, ident=id, - verbosity=4 + verbosity=3 ) - DATA[id] = r + sendbuf = open(f'{PRIVATE_DIR}/artifacts/{id}/sendbuf', 'r') + recvbuf = open(f'{PRIVATE_DIR}/artifacts/{id}/recvbuf', 'w') + DATA[id] = { + 'runner': r, + 'sendbuf': sendbuf, + 'recvbuf': recvbuf, + } session['runner'] = id return make_response('', 201, {'Location': f'/runners/{id}'}) -@app.route('/runners/<id>') +@app.put('/runners/<id>') def job_status(id): if id != session.get('runner'): abort(401) - return { - 'status': DATA[id].status, - } + data = request.json + if data: + DATA[id]['recvbuf'].write(json.dumps(data) + '\n') + DATA[id]['recvbuf'].flush() + + sendline = DATA[id]['sendbuf'].readline() + if sendline: + res = json.loads(sendline) + else: + res = {} + + res['status'] = DATA[id]['runner'].status + + return res if __name__ == '__main__':