#!/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 = """ garden rebuild """ 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()