"""Plane webhook receiver (PAD-7): a delivery for a tenant project triggers a dispatcher tick right away; the 2-minute cron tick stays as the fallback. Stdlib only. POST (default /plane): - X-Plane-Signature = hex HMAC-SHA256 of the request body with the webhook secret (Plane signs json.dumps(payload) and sends the same bytes); missing or wrong -> 401, nothing runs. - body {"event": "issue"|"issue_comment", "action": ..., "data": {..., "project": }, "activity": ...}; data.project not a configured tenant (or absent, e.g. on delete) -> 202, nothing runs. - otherwise -> 200 and the Trigger schedules a tick. GET /healthz -> 200 JSON (pending / running / last tick), no secrets. Trigger: deliveries within `debounce_seconds` coalesce into one tick; deliveries that arrive while a tick runs cause one more tick after it (the running tick may have read Plane before the change). The tick command exits 75 when the cron lock (or agentd's own state lock) is held: then it is retried every `retry_seconds` for up to `retry_window_seconds`; after that the next cron tick picks the change up. Every delivery and tick is logged to /webhook.log.""" import hashlib import hmac import json import os import subprocess import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import ROOT BUSY = 75 # tick command exit code: lock held by another tick (flock -E 75, cli tick on Busy) MAX_BODY = 2 << 20 # Plane payloads are a few KB def sign(secret, body): return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() def verify(secret, body, signature): """Constant-time check of the hex signature against the raw body; falls back to Plane's own serialisation (json.dumps of the parsed payload) in case a proxy re-encoded the body.""" if not secret or not signature: return False sig = signature.strip().lower().encode("ascii", "replace") if hmac.compare_digest(sign(secret, body).encode(), sig): return True try: canonical = json.dumps(json.loads(body)).encode("utf-8") except ValueError: return False return canonical != body and hmac.compare_digest(sign(secret, canonical).encode(), sig) def project_of(payload): data = payload.get("data") if isinstance(payload, dict) else None pid = (data or {}).get("project") or (data or {}).get("project_id") return pid.get("id") if isinstance(pid, dict) else pid def route(payload, projects): """Configured project name for the delivery, or None.""" pid = project_of(payload) return next((name for name, p in projects.items() if pid and p["id"] == pid), None) class Logger: def __init__(self, path): self.path = path self.lock = threading.Lock() def __call__(self, msg): line = time.strftime("%Y-%m-%dT%H:%M:%S") + " " + msg + "\n" with self.lock: if self.path: os.makedirs(os.path.dirname(self.path), exist_ok=True) with open(self.path, "a", encoding="utf-8") as fh: fh.write(line) else: print(line, end="", flush=True) class Trigger: """Debounced tick trigger. `run()` executes one tick and returns its exit code (BUSY = lock held).""" def __init__(self, run, debounce=3.0, retry=15.0, retry_window=120.0, log=print, clock=time.monotonic): self.run, self.debounce, self.retry, self.retry_window = run, debounce, retry, retry_window self.log, self.clock = log, clock self.cv = threading.Condition() self.due = None # clock() when the next tick starts; None = nothing pending self.reasons = [] # deliveries folded into the pending tick self.busy_since = None # first BUSY of the current retry chain self.running = False self.last = {} self.ticks = 0 def kick(self, reason): with self.cv: if self.due is None: self.due = self.clock() + self.debounce self.reasons.append(reason) self.cv.notify() def status(self): with self.cv: return {"pending": self.due is not None, "queued": len(self.reasons), "running": self.running, "retrying_since": self.busy_since and round(self.clock() - self.busy_since, 1), "ticks": self.ticks, "last": dict(self.last)} def start(self): threading.Thread(target=self.serve_forever, name="tick-trigger", daemon=True).start() return self def serve_forever(self): while True: self.step() def step(self, wait=None): """Wait until a tick is due (or `wait` seconds pass), run it, handle the result. True if a tick ran.""" with self.cv: end = None if wait is None else self.clock() + wait while self.due is None or self.due > self.clock(): timeout = None if self.due is None else self.due - self.clock() if end is not None: left = end - self.clock() if left <= 0: return False timeout = left if timeout is None else min(timeout, left) self.cv.wait(timeout) reasons, self.reasons, self.due, self.running = self.reasons, [], None, True start = self.clock() self.log("tick start (%d deliveries: %s)" % (len(reasons), ", ".join(reasons[:5]) + (" …" if len(reasons) > 5 else ""))) try: rc = self.run() except Exception as exc: # a broken runner must not kill the trigger thread rc = "error %r" % (exc,) took = round(self.clock() - start, 1) with self.cv: self.running = False self.ticks += 1 self.last = {"rc": rc, "seconds": took, "deliveries": len(reasons), "at": time.strftime("%Y-%m-%dT%H:%M:%S")} if rc != BUSY: self.busy_since = None self.log("tick done rc=%s in %.1fs" % (rc, took)) return True now = self.clock() self.busy_since = self.busy_since or start if now - self.busy_since + self.retry > self.retry_window: self.log("tick busy for %.0fs, giving up; the next cron tick covers it" % (now - self.busy_since)) self.busy_since = None return True self.reasons = reasons + self.reasons self.due = min(self.due, now + self.retry) if self.due is not None else now + self.retry self.log("tick busy (another tick holds the lock), retry in %gs" % self.retry) return True def command_runner(cmd, env, timeout=1000): """Run the tick command (it logs to cron.log itself); returns its exit code.""" def run(): try: return subprocess.run(cmd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout).returncode except subprocess.TimeoutExpired: return "timeout" return run def make_handler(secret, projects, trigger, log, path="/plane"): class Handler(BaseHTTPRequestHandler): server_version = "plane-agentd-webhook" def log_message(self, fmt, *args): # deliveries are logged by us, one line each pass def reply(self, code, obj): body = json.dumps(obj).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path.split("?")[0] == "/healthz": return self.reply(200, dict(ok=True, **trigger.status())) self.reply(404, {"error": "not found"}) def do_POST(self): if self.path.split("?")[0] != path: return self.reply(404, {"error": "not found"}) delivery = self.headers.get("X-Plane-Delivery", "-") try: n = int(self.headers.get("Content-Length", "")) except ValueError: n = -1 if not 0 <= n <= MAX_BODY: log("delivery=%s from=%s decision=400 bad length" % (delivery, self.client_address[0])) return self.reply(400, {"error": "bad length"}) body = self.rfile.read(n) sig = self.headers.get("X-Plane-Signature") if not verify(secret, body, sig): log("delivery=%s from=%s decision=401 %s signature" % ( delivery, self.client_address[0], "bad" if sig else "missing")) return self.reply(401, {"error": "signature"}) try: payload = json.loads(body) except ValueError: log("delivery=%s decision=400 bad json" % delivery) return self.reply(400, {"error": "json"}) event = "%s/%s" % (payload.get("event") or self.headers.get("X-Plane-Event"), payload.get("action")) name = route(payload, projects) what = "delivery=%s event=%s project=%s item=%s" % ( delivery, event, name or project_of(payload) or "-", (payload.get("data") or {}).get("id", "-")) if not name: log(what + " decision=202 ignored (not a tenant project)") return self.reply(202, {"ignored": "project"}) trigger.kick(delivery) log(what + " decision=tick") self.reply(200, {"tick": "scheduled"}) return Handler def serve(cfg, bind=None, port=None): """`agentd webhook-serve`: the secret comes from the environment (agent-access.py exec --secret-env).""" w = cfg.webhook secret = os.environ.get(w["secret_key"]) if not secret: raise SystemExit("webhook secret %s not in the environment; run under agent-access.py exec --secret-env %s=%s" % (w["secret_key"], w["secret_key"], w["secret_key"])) log = Logger(str(cfg.log_dir / "webhook.log")) cmd = list(w["tick_command"]) if "/" in cmd[0] and not os.path.isabs(cmd[0]): cmd[0] = str(ROOT / cmd[0]) # relative script path = repo run = command_runner(cmd, cfg.scrubbed_env(), w.get("tick_timeout_seconds", 1000)) trigger = Trigger(run, w.get("debounce_seconds", 3), w.get("retry_seconds", 15), w.get("retry_window_seconds", 120), log=log).start() bind, port = bind or w.get("bind", "127.0.0.1"), port or w.get("port", 8790) httpd = ThreadingHTTPServer((bind, port), make_handler(secret, cfg.projects, trigger, log, w.get("path", "/plane"))) log("listening on http://%s:%s%s (tenants: %s)" % (bind, port, w.get("path", "/plane"), ", ".join(cfg.projects))) httpd.serve_forever()