blob: 347501c98451ac494887e6fe737e884159038f5c [file]
#!/usr/bin/env python3
"""GitHub webhook receiver for the ATS CI Git mirrors."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import subprocess
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import urlsplit
EXPECTED_REPOS = {
"apache/trafficserver": "trafficserver",
"apache/trafficserver-ci": "trafficserver-ci",
}
def getenv_int(name: str, default: int) -> int:
value = os.environ.get(name)
if value is None:
return default
try:
return int(value)
except ValueError:
raise SystemExit(f"{name} must be an integer")
WEBHOOK_HOST = os.environ.get("WEBHOOK_HOST", "127.0.0.1")
WEBHOOK_PORT = getenv_int("WEBHOOK_PORT", 9419)
WEBHOOK_PATH = os.environ.get("WEBHOOK_PATH", "/github-mirror-webhook")
WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET")
UPDATE_MIRROR = os.environ.get(
"UPDATE_MIRROR",
"/opt/github-mirror/bin/update-mirror.sh",
)
MIRROR_ROOT = os.environ.get("MIRROR_ROOT", "/home/mirror")
MAX_BODY_BYTES = getenv_int("MAX_BODY_BYTES", 1024 * 1024)
UPDATE_TIMEOUT_SECONDS = getenv_int("UPDATE_TIMEOUT_SECONDS", 600)
def log(message: str) -> None:
print(f"github-mirror-webhook: {message}", file=sys.stderr, flush=True)
def verify_signature(body: bytes, header_value: str | None) -> bool:
if not WEBHOOK_SECRET or WEBHOOK_SECRET == "CHANGE_ME":
log("GITHUB_WEBHOOK_SECRET is not configured")
return False
if not header_value or not header_value.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, header_value)
def run_update(*args: str) -> str:
command = [UPDATE_MIRROR, *args]
env = os.environ.copy()
env["MIRROR_ROOT"] = MIRROR_ROOT
start = time.monotonic()
completed = subprocess.run(
command,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=UPDATE_TIMEOUT_SECONDS,
check=False,
)
elapsed = time.monotonic() - start
output = completed.stdout.strip()
if output:
for line in output.splitlines():
log(line)
if completed.returncode != 0:
raise RuntimeError(
f"{' '.join(command)} failed with exit {completed.returncode}"
)
return f"updated via {' '.join(command)} in {elapsed:.1f}s"
def repo_name(payload: dict[str, Any]) -> str:
repository = payload.get("repository")
if not isinstance(repository, dict):
raise ValueError("payload missing repository object")
full_name = repository.get("full_name")
if not isinstance(full_name, str):
raise ValueError("payload missing repository.full_name")
if full_name not in EXPECTED_REPOS:
raise PermissionError(f"unexpected repository: {full_name}")
return full_name
def handle_event(event: str, payload: dict[str, Any]) -> tuple[int, str]:
full_name = repo_name(payload)
mirror_repo = EXPECTED_REPOS[full_name]
if event == "ping":
return 200, f"pong for {full_name}\n"
if event == "push":
result = run_update(mirror_repo, "--heads-tags")
return 200, result + "\n"
if event == "pull_request":
if mirror_repo != "trafficserver":
raise ValueError("pull_request events are only accepted for apache/trafficserver")
number = payload.get("number")
if not isinstance(number, int):
raise ValueError("payload missing integer pull request number")
action = payload.get("action")
if action == "closed":
result = run_update("trafficserver", "--delete-pr", str(number), "--heads-tags")
else:
result = run_update("trafficserver", "--pr", str(number))
return 200, result + "\n"
raise ValueError(f"unsupported event: {event}")
class WebhookHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def send_text(self, status: int, body: str) -> None:
encoded = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = urlsplit(self.path).path
if path != WEBHOOK_PATH:
self.send_text(404, "not found\n")
return
event = self.headers.get("X-GitHub-Event", "")
delivery = self.headers.get("X-GitHub-Delivery", "unknown-delivery")
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self.send_text(400, "invalid content length\n")
return
if length <= 0:
self.send_text(400, "empty request body\n")
return
if length > MAX_BODY_BYTES:
self.send_text(413, "request body too large\n")
return
body = self.rfile.read(length)
if not verify_signature(body, self.headers.get("X-Hub-Signature-256")):
log(f"rejected delivery={delivery} event={event}: bad signature")
self.send_text(401, "bad signature\n")
return
try:
payload = json.loads(body.decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("payload root is not an object")
status, response = handle_event(event, payload)
log(f"accepted delivery={delivery} event={event}: {response.strip()}")
self.send_text(status, response)
except PermissionError as exc:
log(f"rejected delivery={delivery} event={event}: {exc}")
self.send_text(403, f"{exc}\n")
except (ValueError, json.JSONDecodeError) as exc:
log(f"bad delivery={delivery} event={event}: {exc}")
self.send_text(400, f"{exc}\n")
except subprocess.TimeoutExpired:
log(f"timed out delivery={delivery} event={event}")
self.send_text(504, "mirror update timed out\n")
except Exception as exc: # Keep GitHub delivery diagnostics explicit.
log(f"failed delivery={delivery} event={event}: {exc}")
self.send_text(500, f"{exc}\n")
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = urlsplit(self.path).path
if path == "/healthz":
self.send_text(200, "ok\n")
else:
self.send_text(405, "method not allowed\n")
def log_message(self, fmt: str, *args: Any) -> None:
log(fmt % args)
def main() -> int:
if not WEBHOOK_SECRET or WEBHOOK_SECRET == "CHANGE_ME":
log("refusing to start without GITHUB_WEBHOOK_SECRET")
return 1
server = ThreadingHTTPServer((WEBHOOK_HOST, WEBHOOK_PORT), WebhookHandler)
log(f"listening on http://{WEBHOOK_HOST}:{WEBHOOK_PORT}{WEBHOOK_PATH}")
try:
server.serve_forever()
except KeyboardInterrupt:
log("shutting down")
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())