129 lines
4.7 KiB
Python
129 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Gitea webhook handler — agent-coding-empire dispatcher.
|
|
|
|
Listens on TCP :18790 for Gitea webhook deliveries. When an issue acquires the
|
|
label "agent:assign" (or is opened with that label), SSH-triggers dev-task.sh
|
|
on dev-01 (192.168.1.29).
|
|
|
|
Verifies the X-Gitea-Signature HMAC header against WEBHOOK_SECRET from
|
|
/etc/agent/webhook-secret.
|
|
|
|
This is a deliberately tiny single-file service. It uses stdlib only.
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import http.server
|
|
import json
|
|
import logging
|
|
import os
|
|
import socketserver
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0")
|
|
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "18790"))
|
|
SECRET_FILE = Path(os.environ.get("WEBHOOK_SECRET_FILE", "/etc/agent/webhook-secret"))
|
|
DEV_HOST = os.environ.get("DEV_HOST", "agent@192.168.1.29")
|
|
SSH_KEY = os.environ.get("SSH_KEY", "/root/.ssh/id_ed25519_pve")
|
|
LABEL = os.environ.get("AGENT_LABEL", "agent:assign")
|
|
LOG_DIR = Path(os.environ.get("HANDLER_LOG_DIR", "/var/log/agent-webhook"))
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
)
|
|
log = logging.getLogger("webhook")
|
|
|
|
SECRET = SECRET_FILE.read_text().strip()
|
|
|
|
|
|
def verify_signature(body: bytes, sig_header: str) -> bool:
|
|
if not sig_header:
|
|
return False
|
|
mac = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
|
return hmac.compare_digest(mac, sig_header)
|
|
|
|
|
|
def should_dispatch(event: str, payload: dict) -> tuple[bool, str | None, int | None]:
|
|
"""Decide if this delivery should kick off a dev-task. Returns (yes, repo, issue#)."""
|
|
if event != "issues":
|
|
return False, None, None
|
|
action = payload.get("action", "")
|
|
issue = payload.get("issue", {})
|
|
repo = payload.get("repository", {}).get("full_name")
|
|
number = issue.get("number")
|
|
labels = [l.get("name", "") for l in issue.get("labels", [])]
|
|
if action == "opened" and LABEL in labels:
|
|
return True, repo, number
|
|
if action == "label_updated" and LABEL in labels:
|
|
# Gitea fires label_updated for both add and remove; we only care about presence
|
|
return True, repo, number
|
|
return False, None, None
|
|
|
|
|
|
def dispatch(repo: str, issue: int):
|
|
"""SSH into dev-01 and fire dev-task in the background."""
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
fn = LOG_DIR / f"dispatch-{repo.replace('/', '_')}-issue-{issue}-{int(time.time())}.log"
|
|
log.info("dispatching dev-task for %s#%d -> %s", repo, issue, fn)
|
|
cmd = [
|
|
"ssh", "-i", SSH_KEY,
|
|
"-o", "BatchMode=yes",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
DEV_HOST,
|
|
f"PATH=$HOME/.local/bin:/usr/local/bin:$PATH "
|
|
f"nohup /usr/local/bin/dev-task.sh '{repo}' {issue} "
|
|
f">>/var/agent/logs/dispatch-issue-{issue}.log 2>&1 &"
|
|
]
|
|
with open(fn, "wb") as f:
|
|
rc = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT).returncode
|
|
log.info("dispatch ssh rc=%d", rc)
|
|
|
|
|
|
class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
n = int(self.headers.get("Content-Length", "0"))
|
|
body = self.rfile.read(n)
|
|
sig = self.headers.get("X-Gitea-Signature", "")
|
|
ev = self.headers.get("X-Gitea-Event", "")
|
|
|
|
if not verify_signature(body, sig):
|
|
log.warning("bad signature for event=%s", ev)
|
|
self.send_response(401); self.end_headers(); self.wfile.write(b"bad sig\n"); return
|
|
|
|
try:
|
|
payload = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
self.send_response(400); self.end_headers(); self.wfile.write(b"bad json\n"); return
|
|
|
|
ok, repo, issue = should_dispatch(ev, payload)
|
|
if ok and repo and issue is not None:
|
|
threading.Thread(target=dispatch, args=(repo, issue), daemon=True).start()
|
|
self.send_response(202); self.end_headers()
|
|
self.wfile.write(b"dispatched\n")
|
|
else:
|
|
self.send_response(200); self.end_headers()
|
|
self.wfile.write(b"ignored\n")
|
|
|
|
def do_GET(self):
|
|
if self.path == "/healthz":
|
|
self.send_response(200); self.end_headers(); self.wfile.write(b"ok\n")
|
|
else:
|
|
self.send_response(404); self.end_headers()
|
|
|
|
def log_message(self, fmt, *args):
|
|
log.info("%s - %s", self.address_string(), fmt % args)
|
|
|
|
|
|
class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
daemon_threads = True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
log.info("agent-webhook listening on %s:%d, target=%s, label=%s",
|
|
LISTEN_HOST, LISTEN_PORT, DEV_HOST, LABEL)
|
|
ThreadedServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler).serve_forever()
|