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:
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__':