Files
wesandClaude Fable 5 3d70e23471 Manual rebuild hook + polish: single title h1, '›' tree marker
- deploy/rebuild-hook.py: tiny stdlib HTTP service (:18101) — button
  page for the Homepage dashboard iframe, POST /rebuild runs
  auto-build.sh --force, /status + /healthz for feedback/monitoring
- auto-build.sh: --force flag + flock so timer and manual builds
  serialize
- [...slug].astro: skip the synthetic h1 when the note body already
  opens with an identical h1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:45:36 -04:00

180 lines
5.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""LAN-only HTTP trigger for manual garden rebuilds.
Runs as a nox user service on cyrion (deploy/garden-rebuild-hook.service),
listening on :18101. Reached at https://garden-hook.c0smere.net via the
internal Caddy (Blocky maps the name; never published through NPM). The
Homepage dashboard embeds GET / as an iframe widget — that page is the
"redeploy" button.
GET / button page (transparent bg, blends into the dashboard)
POST /rebuild start deploy/auto-build.sh --force; 409 while one runs
GET /status {"running", "started", "last_exit", "last_finished", "log_tail"}
GET /healthz 200 "ok" (for Gatus)
Build concurrency with the 5-min timer is handled by the flock inside
auto-build.sh; this server only prevents stacking multiple manual runs.
"""
import json
import os
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPT = os.path.join(REPO, 'deploy', 'auto-build.sh')
LOG = os.path.join(REPO, '.rebuild.log')
PORT = int(os.environ.get('HOOK_PORT', '18101'))
_lock = threading.Lock()
_state = {'proc': None, 'started': None, 'last_exit': None, 'last_finished': None}
def _reap(proc):
proc.wait()
with _lock:
_state['last_exit'] = proc.returncode
_state['last_finished'] = time.time()
def start_build():
with _lock:
if _state['proc'] is not None and _state['proc'].poll() is None:
return False
logf = open(LOG, 'w')
_state['proc'] = subprocess.Popen(
[SCRIPT, '--force'],
stdout=logf,
stderr=subprocess.STDOUT,
cwd=REPO,
)
logf.close()
_state['started'] = time.time()
threading.Thread(target=_reap, args=(_state['proc'],), daemon=True).start()
print(f'rebuild triggered {time.strftime("%F %T")}', flush=True)
return True
def status():
with _lock:
running = _state['proc'] is not None and _state['proc'].poll() is None
tail = ''
try:
with open(LOG, encoding='utf-8', errors='replace') as f:
tail = ''.join(f.readlines()[-12:])
except OSError:
pass
return {
'running': running,
'started': _state['started'],
'last_exit': _state['last_exit'],
'last_finished': _state['last_finished'],
'log_tail': tail,
}
PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>garden rebuild</title>
<style>
body { margin: 0; padding: 10px 12px; background: transparent;
color: #e6e6e6; font-family: 'JetBrains Mono', ui-monospace,
Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; }
button { font: inherit; font-weight: 700; color: #fff; background: #218107;
border: 0; border-radius: 6px; padding: 6px 14px; cursor: pointer; }
button:hover { background: #2fa50f; }
button:disabled { opacity: .55; cursor: default; }
#st { margin-left: .7em; color: rgba(230,230,230,.64); }
.ok { color: #46c421; }
.err { color: #e0563c; }
</style>
</head>
<body>
<button id="go">rebuild garden &#9656;</button><span id="st">&#8230;</span>
<script>
const st = document.getElementById('st');
const go = document.getElementById('go');
const fmt = (t) => (t ? new Date(t * 1000).toLocaleTimeString() : '?');
async function poll() {
try {
const s = await (await fetch('status')).json();
go.disabled = s.running;
if (s.running) {
st.textContent = 'building\\u2026 started ' + fmt(s.started);
st.className = '';
} else if (s.last_exit === null) {
st.textContent = 'idle';
st.className = '';
} else if (s.last_exit === 0) {
st.textContent = 'ok \\u00b7 finished ' + fmt(s.last_finished);
st.className = 'ok';
} else {
st.textContent = 'FAILED (exit ' + s.last_exit + ') \\u00b7 ' +
fmt(s.last_finished);
st.className = 'err';
}
} catch {
st.textContent = 'hook unreachable';
st.className = 'err';
}
}
go.addEventListener('click', async () => {
go.disabled = true;
try { await fetch('rebuild', { method: 'POST' }); } catch {}
poll();
});
poll();
setInterval(poll, 3000);
</script>
</body>
</html>
"""
class Handler(BaseHTTPRequestHandler):
server_version = 'garden-hook'
def _send(self, code, body, ctype='application/json'):
data = body.encode() if isinstance(body, str) else body
self.send_response(code)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(data)
def do_GET(self):
path = self.path.split('?', 1)[0]
if path == '/':
self._send(200, PAGE, 'text/html; charset=utf-8')
elif path == '/status':
self._send(200, json.dumps(status()))
elif path == '/healthz':
self._send(200, 'ok', 'text/plain')
else:
self._send(404, '{"error": "not found"}')
def do_POST(self):
path = self.path.split('?', 1)[0]
if path == '/rebuild':
if start_build():
self._send(202, '{"started": true}')
else:
self._send(409, '{"started": false, "error": "already running"}')
else:
self._send(404, '{"error": "not found"}')
def log_message(self, fmt, *args):
pass # journald noise; triggers are logged in start_build
if __name__ == '__main__':
srv = ThreadingHTTPServer(('0.0.0.0', PORT), Handler)
print(f'garden rebuild hook listening on :{PORT}', flush=True)
srv.serve_forever()