aoh

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

commit a944005a50cb94d88520af530397e35eec6c3108
parent d12459d1c6e7c5c0a23f857eb63b9cc98a0062e2
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Sun, 30 Aug 2026 19:26:25 -0700

Implement keep-alive messages

Diffstat:
Maoh/client.py | 66++++++++++++++++++++++++++++++++++++++++++++++--------------------
Maoh/connection_plugins/aoh.py | 57++++++++++++++++++++++++++++-----------------------------
2 files changed, 74 insertions(+), 49 deletions(-)

diff --git a/aoh/client.py b/aoh/client.py @@ -20,12 +20,15 @@ PASSWORD_PROMPTS = { 'vault_password': 'Vault password: ', } +# Set 10s EXEC keep alive interval (see also TIMEOUT in aoh.py) +KEEP_ALIVE_INTERVAL = 10 + class ClientError(Exception): """Raised for miscellaneous client errors.""" -def exec(args): +def exec(args, keep_alive_handler=None): """Run a command on the local host.""" try: @@ -36,6 +39,14 @@ def exec(args): stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) + + while p.returncode is None: + try: + p.wait(KEEP_ALIVE_INTERVAL) + except subprocess.TimeoutExpired: + if keep_alive_handler: + keep_alive_handler() + stdout, stderr = p.communicate() return { 'returncode': p.returncode, @@ -103,33 +114,48 @@ def send_json_request(url, req_data=None, method='GET', cookies=''): return status_code, headers, json.loads(data) -def runner_loop(runner_url, cookies): - """Main execution loop.""" +def runner_update(url, cookies, data, keep_alive=False): + """Submit runner updates and process core response fields.""" - req = {} + status, _, res = send_json_request(url, data, 'PUT', cookies) - while True: - status, _, res = send_json_request(runner_url, req, 'PUT', cookies) + if 'logs' in res: + # Strip duplicate password prompts + logs = res['logs'] + while any(logs.startswith(prompt[:-2]) for prompt in + PASSWORD_PROMPTS.values()): + logs = logs.split('\n', 1)[1] + + print(logs, end='') + + if 'err' in res: + raise ClientError(res['err']) + elif status != 200: + raise ClientError(f'Server responded with {status}') + elif keep_alive and not res.get('keep-alive'): + raise ClientError('Received invalid keep-alive response') + elif res.get('finished'): + sys.exit(0) - if 'logs' in res: - # Strip duplicate password prompts - logs = res['logs'] - while any(logs.startswith(prompt[:-2]) for prompt in - PASSWORD_PROMPTS.values()): - logs = logs.split('\n', 1)[1] + return res - print(logs, end='') - if 'err' in res: - raise ClientError(res['err']) - elif status != 200: - raise ClientError(f'Server responded with {status}') +def runner_loop(runner_url, cookies): + """Main execution loop.""" + + req = {} - if res.get('finished'): - return + while True: + res = runner_update(runner_url, cookies, req) if 'exec' in res: - req = exec(res['exec']) + keep_alive_handler = lambda res=res: runner_update( + runner_url, + cookies, + { 'id': res['id'], 'keep-alive': True }, + keep_alive=True + ) + req = exec(res['exec'], keep_alive_handler) req['id'] = res['id'] elif 'put' in res: req = put(res['put']) diff --git a/aoh/connection_plugins/aoh.py b/aoh/connection_plugins/aoh.py @@ -12,19 +12,6 @@ DOCUMENTATION = """ required: true vars: - name: ansible_aoh_dir - aoh_timeout: - description: The timeout for AoH responses, in seconds - type: integer - default: 300 - env: - - name: ANSIBLE_TIMEOUT - ini: - - key: timeout - section: defaults - vars: - - name: ansible_aoh_timeout - cli: - - name: timeout """ import base64 @@ -45,6 +32,9 @@ from ansible.utils.display import Display display = Display() +# Set 1m timeout (see also KEEP_ALIVE_INTERVAL in client.py) +TIMEOUT = 60 + class Connection(ConnectionBase): """Ansible-over-HTTP (AoH) connection.""" @@ -107,22 +97,31 @@ class Connection(ConnectionBase): self.sendbuf.write(json.dumps(msg) + '\n') self.sendbuf.flush() - if not self.recvpoll.poll(self.get_option('aoh_timeout') * 1000): - raise AnsibleConnectionFailure('Timed out waiting for AoH response') - - try: - res = json.loads(self.recvbuf.readline()) - except json.decoder.JSONDecodeError: - raise AnsibleError('Received corrupt AoH message') - - if not 'id' in msg: - raise AnsibleError('Received AoH message without ID') - elif res['id'] != msg['id']: - raise AnsibleError('Received AoH message with unexpected ID') - elif 'err' in res: - raise AnsibleError(str(res['err'])) - - return res + while True: + if not self.recvpoll.poll(TIMEOUT * 1000): + raise AnsibleConnectionFailure('Timed out waiting for AoH ' + 'response') + + try: + res = json.loads(self.recvbuf.readline()) + except json.decoder.JSONDecodeError: + raise AnsibleError('Received corrupt AoH message') + + if not 'id' in msg: + raise AnsibleError('Received AoH message without ID') + elif res['id'] != msg['id']: + raise AnsibleError('Received AoH message with unexpected ID') + elif 'err' in res: + raise AnsibleError(str(res['err'])) + + if not res.get('keep-alive'): + return res + + self.sendbuf.write(json.dumps({ + 'id': msg['id'], + 'keep-alive': True, + }) + '\n') + self.sendbuf.flush() def exec_command(self, cmd: str, in_data: bytes | None = None,