aoh

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

commit e8a87e0d7d7cfdcc0c062ba5e03bcce313db9442
parent 72277aa96ab12512fa01214ea9c75699452166ed
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Sun, 30 Aug 2026 12:29:38 -0700

Add optional playbook web pages

Diffstat:
Maoh/client.py | 2+-
Maoh/playbook.py | 2++
Maoh/server.py | 25+++++++++++++++++++++----
Aaoh/static/copy.js | 23+++++++++++++++++++++++
Aaoh/static/global.css | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aaoh/templates/client.py | 2++
Aaoh/templates/home.html | 31+++++++++++++++++++++++++++++++
7 files changed, 177 insertions(+), 5 deletions(-)

diff --git a/aoh/client.py b/aoh/client.py @@ -11,7 +11,7 @@ import time from urllib.error import HTTPError from urllib.request import Request, urlopen -API = '{{ API_URL }}' # Substitution performed by Flask +API = '{{ API_URL }}' #{# Substitution performed by Flask #} PASSWORD_PROMPTS = { 'aoh_password': 'AoH password: ', diff --git a/aoh/playbook.py b/aoh/playbook.py @@ -26,6 +26,7 @@ class Playbook: 'jinja': bool, 'allow_opts': list, # We'll just assume that elements are strings 'block_opts': list, + 'web_description': str, } for key, _type in _types.items(): @@ -43,6 +44,7 @@ class Playbook: self.jinja = dict.get('jinja', False) self.allow_opts = dict.get('allow_opts', []) self.block_opts = dict.get('block_opts', []) + self.web_description = dict.get('web_description') self.path = os.path.abspath(os.path.join(PLAYBOOKS_DIR, self.path)) self.config = os.path.abspath(os.path.join(PLAYBOOKS_DIR, self.config)) diff --git a/aoh/server.py b/aoh/server.py @@ -1,6 +1,6 @@ import os -from flask import Flask, render_template, request, session +from flask import Flask, abort, render_template, request, session from flask_apscheduler import APScheduler from .playbook import get_playbook @@ -10,7 +10,7 @@ from .security import * _RUNNERS = {} _GC_INTERVAL = 60 # 1 minute -app = Flask(__name__, template_folder=os.path.dirname(__file__)) +app = Flask(__name__) app.secret_key = os.urandom(16) scheduler = APScheduler() @@ -28,11 +28,17 @@ def gc(): _RUNNERS.pop(id, None) +@app.context_processor +def inject_stage_and_region(): + return { + 'API_URL': os.getenv('AOH_ORIGIN', request.host_url[:-1]) + } + + @app.get('/run') @app.get('/run.py') def install(): - api_url = os.getenv('AOH_ORIGIN', request.host_url[:-1]) - return render_template('client.py', API_URL=api_url) + return render_template('client.py') @app.post('/runners/<path:path>') @@ -88,3 +94,14 @@ def runner_update(path, short_id): _RUNNERS.pop(id, None) return res + + +@app.get('/') +@app.get('/<path:path>') +def web(path='main.yml'): + playbook = get_playbook(path) + if not playbook or playbook.web_description is None: + return abort(404) + + return render_template('home.html', description=playbook.web_description, + playbook=playbook.name) diff --git a/aoh/static/copy.js b/aoh/static/copy.js @@ -0,0 +1,23 @@ +// Feather icons +const COPY_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'; +const DONE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>' + +// Show copy button only if browser supports JavaScript and clipboard API +function show_copy_btn() { + if ('clipboard' in navigator) { + document.getElementById('copy').innerHTML = COPY_SVG; + document.getElementById('copy').hidden = false; + } +} + +// Copy command to clipboard +function copy(cmd) { + navigator.clipboard.writeText(cmd); + + document.getElementById('copy').disabled = true; + document.getElementById('copy').innerHTML = DONE_SVG; + setTimeout(() => { + document.getElementById('copy').innerHTML = COPY_SVG; + document.getElementById('copy').disabled = false; + }, 1000); +} diff --git a/aoh/static/global.css b/aoh/static/global.css @@ -0,0 +1,97 @@ +/* Colorschemes */ +:root { + --bg1: #fff; + --bg2: #eee; + --bg3: #e6e6e6; + --fg2: #444; + --fg1: #000; +} +@media only screen and (prefers-color-scheme: dark) { + :root { + --bg1: #111; + --bg2: #222; + --bg3: #2a2a2a; + --fg2: #bbb; + --fg1: #ddd; + } +} + +/* Page setup */ +* { + margin: 0; + border: 0; + padding: 0; + box-sizing: border-box; +} +body { + color: var(--fg2); + background-color: var(--bg1); + + font-family: sans-serif; +} +main { + padding: 1rem; + min-height: 100vh; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +/* Description */ +h1 { + margin-bottom: 2rem; + + color: var(--fg2); + + font-size: 2rem; + font-weight: 100; + text-align: center; +} + +/* Command container */ +#cmd-bar { + padding: 1rem 2rem; + border-radius: 1.5rem; + + background-color: var(--bg2); + box-shadow: 0 0 2rem #60686c; + + display: flex; + align-items: center; + gap: 2rem; +} + +/* Command text */ +#cmd { + color: var(--fg1); + + font-size: 1.5rem; + font-family: 'Menlo', 'Cascadia Code', 'Lucida Console', monospace; +} +#cmd::before { + content: '$ '; +} + +/* Command copy button */ +#copy { + width: 3rem; + height: 3rem; + margin: -0.5rem; + border-radius: 0.5rem; + padding: 0.5rem; + + background-color: var(--bg2); + + cursor: pointer; +} +#copy:hover:not(:disabled) { + background-color: var(--bg3); +} +#copy svg { + width: 100%; + height: 100%; + + color: var(--fg1); +} diff --git a/aoh/templates/client.py b/aoh/templates/client.py @@ -0,0 +1 @@ +../client.py +\ No newline at end of file diff --git a/aoh/templates/home.html b/aoh/templates/home.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> + <head> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + {% if description %} + <meta name="description" content="{{ description }}"> + {% endif %} + + <title>{{ playbook }}</title> + + <link rel="stylesheet" href="{{ url_for('static', filename='global.css') }}"> + <script src="{{ url_for('static', filename='copy.js') }}"></script> + </head> + + <body onload="show_copy_btn()"> + <main> + {% if description %} + <h1>{{ description }}</h1> + {% endif %} + + {% set cmd = 'curl %s/run | python3' % API_URL %} + {% if playbook != 'main.yml' %} + {% set cmd = cmd + ' - %s' % playbook %} + {% endif %} + <div id="cmd-bar"> + <code id="cmd">{{ cmd }}</code> + <button id="copy" onclick="copy('{{ cmd }}')" hidden></button> + </div> + </main> + </body> +</html>