314dc03b0d
Moves the orchestrator into bot_bottle/orchestrator/ so one install gets everything. Entry point is now `python -m bot_bottle.orchestrator run`. - Add bot_bottle/orchestrator/ with all 14 modules (verbatim move; internal imports were already relative, so no changes inside orchestrator modules) - Rewrite bootstrap.py: remove the lazy bot_bottle import guard, use direct relative imports from ..contrib.* - Add bot_bottle/contrib/forge/base.py: ScopedForge (read-anywhere / write-scoped) - Add bot_bottle/contrib/gitea/client.py: GiteaClient + GiteaForge (urllib.request only) - Add bot_bottle/contrib/gitea/forge_state.py: ForgeState + SqliteForgeStateStore - Add tests/unit/orchestrator/ (82 tests: 63 migrated + 19 new for contrib modules) Closes #321
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
"""HTTP surface: the Gitea webhook receiver and the provenance API.
|
|
|
|
`POST /webhook` — a Gitea event; parsed and dispatched to the orchestrator.
|
|
`GET /healthz` — liveness.
|
|
`GET /provenance?owner=&repo=&issue=` — the run's audit record (never
|
|
posted to the forge).
|
|
|
|
Webhook signature verification is optional: set a secret and the handler
|
|
rejects bodies whose `X-Gitea-Signature` HMAC-SHA256 does not match.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import json
|
|
from collections.abc import Callable
|
|
from hashlib import sha256
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Any
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from .events import parse_event
|
|
from .lifecycle import Orchestrator
|
|
from .provenance import build_provenance, ops_from_log, provenance_to_dict
|
|
from .store import StateStore
|
|
|
|
# (record) -> that run's op-log entries, injected by bootstrap.
|
|
OpLogReader = Callable[[Any], list[dict[str, Any]]]
|
|
|
|
|
|
class WebhookServer(ThreadingHTTPServer):
|
|
def __init__(
|
|
self,
|
|
address: tuple[str, int],
|
|
*,
|
|
orchestrator: Orchestrator,
|
|
store: StateStore,
|
|
secret: bytes | None = None,
|
|
op_log_reader: OpLogReader | None = None,
|
|
) -> None:
|
|
super().__init__(address, _Handler)
|
|
self.orchestrator = orchestrator
|
|
self.store = store
|
|
self.secret = secret
|
|
self.op_log_reader = op_log_reader
|
|
|
|
|
|
def verify_signature(secret: bytes, body: bytes, signature: str) -> bool:
|
|
expected = hmac.new(secret, body, sha256).hexdigest()
|
|
return hmac.compare_digest(expected, signature or "")
|
|
|
|
|
|
class _Handler(BaseHTTPRequestHandler):
|
|
server: WebhookServer # type: ignore[assignment]
|
|
|
|
def log_message( # pylint: disable=redefined-builtin
|
|
self, format: str, *args: Any
|
|
) -> None: # quiet by default
|
|
pass
|
|
|
|
def _send(self, code: int, payload: dict[str, Any]) -> None:
|
|
body = json.dumps(payload).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_POST(self) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
if urlparse(self.path).path != "/webhook":
|
|
self._send(404, {"error": "not found"})
|
|
return
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
body = self.rfile.read(length)
|
|
if self.server.secret is not None:
|
|
sig = self.headers.get("X-Gitea-Signature", "")
|
|
if not verify_signature(self.server.secret, body, sig):
|
|
self._send(401, {"error": "bad signature"})
|
|
return
|
|
try:
|
|
payload = json.loads(body)
|
|
except ValueError:
|
|
self._send(400, {"error": "invalid json"})
|
|
return
|
|
kind = self.headers.get("X-Gitea-Event", "")
|
|
event = parse_event(kind, payload)
|
|
if event is not None:
|
|
self.server.orchestrator.handle(event)
|
|
self._send(200, {"ok": True, "handled": event is not None})
|
|
|
|
def do_GET(self) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
parsed = urlparse(self.path)
|
|
if parsed.path == "/healthz":
|
|
self._send(200, {"ok": True})
|
|
return
|
|
if parsed.path == "/provenance":
|
|
self._provenance(parse_qs(parsed.query))
|
|
return
|
|
self._send(404, {"error": "not found"})
|
|
|
|
def _provenance(self, query: dict[str, list[str]]) -> None:
|
|
try:
|
|
owner = query["owner"][0]
|
|
repo = query["repo"][0]
|
|
issue = int(query["issue"][0])
|
|
except (KeyError, IndexError, ValueError):
|
|
self._send(400, {"error": "owner, repo, issue required"})
|
|
return
|
|
record = self.server.store.get(owner, repo, issue)
|
|
if record is None:
|
|
self._send(404, {"error": "no such run"})
|
|
return
|
|
reader = self.server.op_log_reader
|
|
ops = ops_from_log(reader(record) if reader is not None else [])
|
|
prov = build_provenance(
|
|
record,
|
|
ops=ops,
|
|
started_at="",
|
|
finished_at=record.last_checkin_at,
|
|
exit_code=None,
|
|
watchdog_fired=False,
|
|
)
|
|
self._send(200, provenance_to_dict(prov))
|