aoh

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

commit b32b1f96961f76d0cc3611856cd5b9570015e3b3
parent f7da3d7c2877d87ce3c9db091e00dd8bf553f679
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Sun, 16 Aug 2026 14:42:41 -0700

Rename project to AoH

Diffstat:
AREADME.md | 1+
Aconnection_plugins/aoh.py | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dconnection_plugins/http.py | 161-------------------------------------------------------------------------------
Mmain.py | 6+++---
4 files changed, 165 insertions(+), 164 deletions(-)

diff --git a/README.md b/README.md @@ -0,0 +1 @@ +# Ansible-over-HTTP (AoH) diff --git a/connection_plugins/aoh.py b/connection_plugins/aoh.py @@ -0,0 +1,161 @@ +# Copyright: (c) 2026, Asher Morgan <asher@ashermorgan.net> +# GNU General Public License v3.0+ + +DOCUMENTATION = r''' + name: aoh + short_description: Run tasks through an Ansible-over-HTTP (AoH) connection + + options: + aoh_dir: + description: The AoH runner directory + required: true + vars: + - name: ansible_aoh_dir +''' + +import base64 +import json +import os +import typing as t + +from ansible.errors import AnsibleError, AnsibleFileNotFound +from ansible.plugins.connection import ConnectionBase +from ansible.utils.display import Display + +display = Display() + + +class Connection(ConnectionBase): + """Ansible-over-HTTP (AoH) connection.""" + + transport = 'aoh' + has_pipelining = False + + + def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + super().__init__(*args, **kwargs) + + self.sendbuf = None + self.recvbuf = None + + def _connect(self) -> Connection: # noqa: F821 + """Connect to the host.""" + + if not self._connected: + AOH_DIR = self.get_option('aoh_dir') + + display.vvv(f'ESTABLISH CONNECTION TO AOH RUNNER {AOH_DIR}', + host=self._play_context.remote_addr) + + RECVBUF_PATH = f'{AOH_DIR}/recvbuf' + SENDBUF_PATH = f'{AOH_DIR}/sendbuf' + + if not os.path.exists(RECVBUF_PATH): + os.mkfifo(RECVBUF_PATH) + if not os.path.exists(SENDBUF_PATH): + os.mkfifo(SENDBUF_PATH) + + self.recvbuf = open(RECVBUF_PATH, 'r') # noqa: SIM115 + self.sendbuf = open(SENDBUF_PATH, 'w') # noqa: SIM115 + + self._connected = True + + return self + + + def exec_command(self, cmd: str, in_data: bytes | None = None, + sudoable: bool = True) -> tuple[int, bytes, bytes]: + """Run a command on the host.""" + + super().exec_command(cmd, in_data=in_data, sudoable=sudoable) + + display.vvv(f'EXEC {cmd}', host=self._play_context.remote_addr) + + 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({ + 'exec': cmd, + }) + '\n') + self.sendbuf.flush() + + res = json.loads(self.recvbuf.readline()) + if 'err' in res: + raise AnsibleError(res['err']) + return ( + res['returncode'], + res['stdout'].encode('latin1'), + res['stderr'].encode('latin1'), + ) + + + def put_file(self, in_path: str, out_path: str) -> None: + """Transfer file to host.""" + + super().put_file(in_path, out_path) + + 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 + + if not os.path.exists(in_path): + raise AnsibleFileNotFound(f'file or module does not exist: {in_path}') + 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() + + res = json.loads(self.recvbuf.readline()) + if 'err' in res: + raise AnsibleError(res['err']) + + + def fetch_file(self, in_path: str, out_path: str) -> None: + """Fetch file from host.""" + + super().fetch_file(in_path, out_path) + + display.vvv(f'FETCH {in_path} TO {out_path}', + host=self._play_context.remote_addr) + + assert self.sendbuf is not None + assert self.recvbuf is not None + + self.sendbuf.write(json.dumps({ + 'fetch': in_path, + }) + '\n') + self.sendbuf.flush() + + res = json.loads(self.recvbuf.readline()) + if 'err' in res: + raise AnsibleError(res['err']) + with open(out_path, 'rb') as f: + f.write(base64.b64decode(res['data'])) + + + def close(self) -> None: + """Close connection.""" + + display.vvv('CLOSE CONNECTION FOR AOH RUNNER ' + f'{self.get_option('runner')}', + host=self._play_context.remote_addr) + + if self.recvbuf: + self.recvbuf.close() + if self.sendbuf: + self.sendbuf.close() + + self._connected = False + + super().close() diff --git a/connection_plugins/http.py b/connection_plugins/http.py @@ -1,161 +0,0 @@ -# Copyright: (c) 2026, Asher Morgan <asher@ashermorgan.net> -# GNU General Public License v3.0+ - -DOCUMENTATION = r''' - name: http - short_description: Control hosts over a "reverse" HTTP connection - - options: - runner: - description: The artifact directory of the associated Ansible runner - required: true - vars: - - name: ansible_http_runner -''' - -import base64 -import json -import os -import typing as t - -from ansible.errors import AnsibleError, AnsibleFileNotFound -from ansible.plugins.connection import ConnectionBase -from ansible.utils.display import Display - -display = Display() - - -class Connection(ConnectionBase): - """Reverse-HTTP connection.""" - - transport = 'http' - has_pipelining = False - - - def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: - super().__init__(*args, **kwargs) - - self.sendbuf = None - self.recvbuf = None - - def _connect(self) -> Connection: # noqa: F821 - """Connect to the host (nop).""" - - if not self._connected: - display.vvv('ESTABLISH HTTP CONNECTION FOR RUNNER ' - f'{self.get_option('runner')}', - host=self._play_context.remote_addr) - - RUNNER_DIR = self.get_option('runner') - RECVBUF_PATH = f'{RUNNER_DIR}/recvbuf' - SENDBUF_PATH = f'{RUNNER_DIR}/sendbuf' - - if not os.path.exists(RECVBUF_PATH): - os.mkfifo(RECVBUF_PATH) - if not os.path.exists(SENDBUF_PATH): - os.mkfifo(SENDBUF_PATH) - - self.recvbuf = open(RECVBUF_PATH, 'r') # noqa: SIM115 - self.sendbuf = open(SENDBUF_PATH, 'w') # noqa: SIM115 - - self._connected = True - - return self - - - def exec_command(self, cmd: str, in_data: bytes | None = None, - sudoable: bool = True) -> tuple[int, bytes, bytes]: - """Run a command on the host.""" - - super().exec_command(cmd, in_data=in_data, sudoable=sudoable) - - display.vvv(f'EXEC {cmd}', host=self._play_context.remote_addr) - - 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({ - 'exec': cmd, - }) + '\n') - self.sendbuf.flush() - - res = json.loads(self.recvbuf.readline()) - if 'err' in res: - raise AnsibleError(res['err']) - return ( - res['returncode'], - res['stdout'].encode('latin1'), - res['stderr'].encode('latin1'), - ) - - - def put_file(self, in_path: str, out_path: str) -> None: - """Transfer file to host.""" - - super().put_file(in_path, out_path) - - 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 - - if not os.path.exists(in_path): - raise AnsibleFileNotFound(f'file or module does not exist: {in_path}') - 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() - - res = json.loads(self.recvbuf.readline()) - if 'err' in res: - raise AnsibleError(res['err']) - - - def fetch_file(self, in_path: str, out_path: str) -> None: - """Fetch file from host.""" - - super().fetch_file(in_path, out_path) - - display.vvv(f'FETCH {in_path} TO {out_path}', - host=self._play_context.remote_addr) - - assert self.sendbuf is not None - assert self.recvbuf is not None - - self.sendbuf.write(json.dumps({ - 'fetch': in_path, - }) + '\n') - self.sendbuf.flush() - - res = json.loads(self.recvbuf.readline()) - if 'err' in res: - raise AnsibleError(res['err']) - with open(out_path, 'rb') as f: - f.write(base64.b64decode(res['data'])) - - - def close(self) -> None: - """Close connection.""" - - display.vvv('CLOSE HTTP CONNECTION FOR RUNNER ' - f'{self.get_option('runner')}', - host=self._play_context.remote_addr) - - if self.recvbuf: - self.recvbuf.close() - if self.sendbuf: - self.sendbuf.close() - - self._connected = False - - super().close() diff --git a/main.py b/main.py @@ -38,7 +38,7 @@ class Runner: self._logs = open(self._LOGS_PATH, 'r') # noqa: SIM115 # The FIFO buffers may not get created if Ansible crashes/exits - # before calling the http connection plugin + # before calling the AoH connection plugin while not os.path.exists(self._SENDBUF_PATH) and \ not self._runner_finished(): time.sleep(0.1) @@ -68,8 +68,8 @@ class Runner: 'ANSIBLE_CONFIG': config, } vars = { - 'ansible_http_runner': self._dir, - 'ansible_connection': 'http', + 'ansible_aoh_dir': self._dir, + 'ansible_connection': 'aoh', } self._thread, self._runner = ansible_runner.run_async(