6dc7e72ac8
agentd webhook-serve (agentd/webhook.py, stdlib): verifies X-Plane-Signature
(HMAC-SHA256 hex of the body, constant-time; 401 on missing/bad), routes by
data.project to a configured tenant (others 202), coalesces deliveries within
3 s into one tick, runs one more tick when deliveries arrive during a tick,
and retries every 15 s for up to 2 min while the lock is held (tick command
exit 75: flock -E 75 in the cron scripts, cli tick on Busy). Ticks run in a
systemd scope so restarting the receiver never kills one. Log:
<log_dir>/webhook.log; GET /healthz.
Config: webhook section (key names only, PLANE_WEBHOOK_SECRET scrubbed from
child envs like the Plane tokens), notify: {kind: none} as a no-op hook
called on every state move; the unused per-project webhook_secret_key is
gone (Plane webhooks are workspace-level). systemd unit for the LXC and
tools/plane-webhook.py to create/rotate the Plane webhook as the workspace
admin (secret straight into the Vault, never printed).
185 lines
7.1 KiB
Python
185 lines
7.1 KiB
Python
import json
|
|
import threading
|
|
import time
|
|
import unittest
|
|
import urllib.error
|
|
import urllib.request
|
|
from http.server import ThreadingHTTPServer
|
|
|
|
from agentd.webhook import BUSY, Trigger, make_handler, route, sign, verify
|
|
|
|
SECRET = "plane_wh_test"
|
|
PROJECTS = {"GWENT": {"id": "proj-gw"}, "PAD": {"id": "proj-pad"}}
|
|
|
|
|
|
def payload(project="proj-gw", event="issue", action="updated"):
|
|
return {"event": event, "action": action, "webhook_id": "w1", "workspace_id": "ws", "workspace_slug": "kappiee",
|
|
"data": {"id": "i1", "project": project}, "activity": {"field": "state"}}
|
|
|
|
|
|
def plane_body(p):
|
|
return json.dumps(p).encode() # what Plane signs (json.dumps(payload)) and sends
|
|
|
|
|
|
class SignatureTest(unittest.TestCase):
|
|
def test_ok_bad_missing(self):
|
|
body = plane_body(payload())
|
|
self.assertTrue(verify(SECRET, body, sign(SECRET, body)))
|
|
self.assertTrue(verify(SECRET, body, sign(SECRET, body).upper()))
|
|
self.assertFalse(verify(SECRET, body, sign("other", body)))
|
|
self.assertFalse(verify(SECRET, body + b" ", sign(SECRET, body + b"x")))
|
|
self.assertFalse(verify(SECRET, body, None))
|
|
self.assertFalse(verify(SECRET, body, ""))
|
|
self.assertFalse(verify("", body, sign("", body))) # no secret configured: reject everything
|
|
self.assertFalse(verify(SECRET, body, "zzé")) # non-hex garbage
|
|
|
|
def test_reencoded_body_matches_plane_serialisation(self):
|
|
p = payload()
|
|
compact = json.dumps(p, separators=(",", ":")).encode()
|
|
self.assertTrue(verify(SECRET, compact, sign(SECRET, plane_body(p))))
|
|
self.assertFalse(verify(SECRET, b"not json", sign(SECRET, plane_body(p))))
|
|
|
|
|
|
class RouteTest(unittest.TestCase):
|
|
def test_route(self):
|
|
self.assertEqual(route(payload(), PROJECTS), "GWENT")
|
|
self.assertEqual(route(payload("proj-pad", "issue_comment", "created"), PROJECTS), "PAD")
|
|
self.assertIsNone(route(payload("proj-other"), PROJECTS))
|
|
self.assertIsNone(route({"event": "issue", "action": "deleted", "data": {"id": "i1"}}, PROJECTS))
|
|
self.assertIsNone(route({"event": "issue"}, PROJECTS))
|
|
self.assertEqual(route({"data": {"project": {"id": "proj-gw"}}}, PROJECTS), "GWENT")
|
|
|
|
|
|
class FakeTrigger:
|
|
def __init__(self):
|
|
self.kicks = []
|
|
|
|
def kick(self, reason):
|
|
self.kicks.append(reason)
|
|
|
|
def status(self):
|
|
return {"pending": bool(self.kicks)}
|
|
|
|
|
|
class HandlerTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.trigger, self.lines = FakeTrigger(), []
|
|
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(SECRET, PROJECTS, self.trigger, self.lines.append))
|
|
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
self.url = "http://127.0.0.1:%d" % self.httpd.server_address[1]
|
|
|
|
def tearDown(self):
|
|
self.httpd.shutdown()
|
|
self.httpd.server_close()
|
|
|
|
def post(self, body, sig, path="/plane"):
|
|
headers = {"Content-Type": "application/json", "X-Plane-Delivery": "d-1", "X-Plane-Event": "issue"}
|
|
if sig is not None:
|
|
headers["X-Plane-Signature"] = sig
|
|
req = urllib.request.Request(self.url + path, data=body, headers=headers, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=5) as r:
|
|
return r.status
|
|
except urllib.error.HTTPError as e:
|
|
return e.code
|
|
|
|
def test_signed_tenant_delivery_triggers(self):
|
|
body = plane_body(payload())
|
|
self.assertEqual(self.post(body, sign(SECRET, body)), 200)
|
|
self.assertEqual(self.trigger.kicks, ["d-1"])
|
|
self.assertIn("project=GWENT", self.lines[-1])
|
|
self.assertIn("decision=tick", self.lines[-1])
|
|
|
|
def test_bad_or_missing_signature_is_401_and_triggers_nothing(self):
|
|
body = plane_body(payload())
|
|
self.assertEqual(self.post(body, sign("wrong", body)), 401)
|
|
self.assertEqual(self.post(body, None), 401)
|
|
self.assertEqual(self.trigger.kicks, [])
|
|
self.assertIn("401 missing", self.lines[-1])
|
|
|
|
def test_other_project_is_202(self):
|
|
body = plane_body(payload("proj-other"))
|
|
self.assertEqual(self.post(body, sign(SECRET, body)), 202)
|
|
self.assertEqual(self.trigger.kicks, [])
|
|
|
|
def test_wrong_path_and_healthz(self):
|
|
body = plane_body(payload())
|
|
self.assertEqual(self.post(body, sign(SECRET, body), "/other"), 404)
|
|
with urllib.request.urlopen(self.url + "/healthz", timeout=5) as r:
|
|
self.assertEqual((r.status, json.loads(r.read())["ok"]), (200, True))
|
|
|
|
|
|
class FakeRunner:
|
|
def __init__(self, codes=(), hold=0.0):
|
|
self.codes, self.hold, self.calls = list(codes), hold, 0
|
|
self.started = threading.Event()
|
|
|
|
def __call__(self):
|
|
self.calls += 1
|
|
self.started.set()
|
|
time.sleep(self.hold)
|
|
return self.codes.pop(0) if self.codes else 0
|
|
|
|
|
|
class TriggerTest(unittest.TestCase):
|
|
def make(self, runner, **kw):
|
|
kw = dict(dict(debounce=0.2, retry=0.1, retry_window=1.0), **kw)
|
|
self.lines = []
|
|
return Trigger(runner, log=self.lines.append, **kw)
|
|
|
|
def test_burst_coalesces_into_one_tick(self):
|
|
run = FakeRunner()
|
|
t = self.make(run)
|
|
for i in range(5):
|
|
t.kick("d%d" % i)
|
|
self.assertTrue(t.step(wait=2))
|
|
self.assertEqual(run.calls, 1)
|
|
self.assertIn("5 deliveries", self.lines[0])
|
|
self.assertFalse(t.step(wait=0.4)) # nothing left
|
|
self.assertEqual(run.calls, 1)
|
|
|
|
def test_debounce_waits_before_ticking(self):
|
|
run = FakeRunner()
|
|
t = self.make(run, debounce=0.3)
|
|
t0 = time.monotonic()
|
|
t.kick("d1")
|
|
t.step(wait=2)
|
|
self.assertGreaterEqual(time.monotonic() - t0, 0.29)
|
|
|
|
def test_delivery_during_a_tick_causes_one_follow_up(self):
|
|
run = FakeRunner(hold=0.3)
|
|
t = self.make(run, debounce=0.05).start()
|
|
t.kick("d1")
|
|
self.assertTrue(run.started.wait(2))
|
|
t.kick("d2") # arrives while the first tick runs
|
|
t.kick("d3")
|
|
time.sleep(1.0)
|
|
self.assertEqual(run.calls, 2)
|
|
self.assertEqual(t.status()["pending"], False)
|
|
|
|
def test_busy_lock_is_retried_until_the_tick_runs(self):
|
|
run = FakeRunner(codes=[BUSY, BUSY, 0])
|
|
t = self.make(run, debounce=0.0)
|
|
t.kick("d1")
|
|
for _ in range(3):
|
|
self.assertTrue(t.step(wait=2))
|
|
self.assertEqual(run.calls, 3)
|
|
self.assertEqual(sum("retry in" in l for l in self.lines), 2)
|
|
self.assertIn("1 deliveries: d1", self.lines[-2]) # the delivery is carried into the retry
|
|
self.assertIsNone(t.status()["retrying_since"])
|
|
self.assertFalse(t.step(wait=0.3))
|
|
|
|
def test_busy_gives_up_after_the_window(self):
|
|
run = FakeRunner(codes=[BUSY] * 10)
|
|
t = self.make(run, debounce=0.0, retry=0.1, retry_window=0.35)
|
|
t.kick("d1")
|
|
while t.step(wait=1):
|
|
pass
|
|
self.assertTrue(any("giving up" in l for l in self.lines))
|
|
self.assertLessEqual(run.calls, 5)
|
|
self.assertFalse(t.status()["pending"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|