generated from coulomb/repo-seed
REUSE-WP-0019-T02: hub recompose staleness tracking + Forgejo webhook
Some checks failed
ci / validate-registry (push) Has been cancelled
Some checks failed
ci / validate-registry (push) Has been cancelled
reuse_surface/hub/store.py: compose_state table tracking composed_at/stale, updated only on a *forced* recompose (refresh=true, webhook, future scheduled fallback) -- a plain GET still serves current best-effort data but never silently reports itself as freshly composed. reuse_surface/hub/webhooks.py: constant-time HMAC-SHA256 signature verification (fails closed on an empty/unconfigured secret) and path-only push-payload inspection (never parses file content, per design principle 2 -- webhook only decides whether to trigger a pull-based recompose). New endpoint POST /v1/webhooks/forgejo, accepting both X-Forgejo-Signature and X-Gitea-Signature headers since sibling repos migrate independently. GET /v1/federated and POST /v1/federated/compose now share an asyncio.Lock with the webhook so concurrent recompose triggers coalesce instead of overlapping. No separate /v1/recompose route was added -- POST /v1/federated/compose already did that job from earlier hub work; specs/FederationHubAPI.md now documents this explicitly instead of duplicating it. 28 new pytest cases (128 total pass). Live-verified: ran the actual hub service locally and sent a real HMAC-signed webhook over HTTP, confirming the full webhook-to-recompose path, signature rejection, and the irrelevant-path no-op. Does NOT deploy to the live reuse.coulomb.social hub -- that needs a container rebuild/push/k8s rollout, a separate production-deployment action out of scope here without explicit sign-off. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -10,6 +12,11 @@ from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from reuse_surface.hub.compose import compose_from_store, DEFAULT_DOMAIN
|
||||
from reuse_surface.hub.store import HubStore
|
||||
from reuse_surface.hub.webhooks import (
|
||||
SIGNATURE_HEADERS,
|
||||
push_touches_registry_index,
|
||||
verify_signature,
|
||||
)
|
||||
|
||||
HUB_VERSION = "0.1.0"
|
||||
|
||||
@@ -30,6 +37,10 @@ def _store() -> HubStore:
|
||||
return HubStore(_db_path())
|
||||
|
||||
|
||||
def _webhook_secret() -> str:
|
||||
return os.environ.get("REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET", "")
|
||||
|
||||
|
||||
def _http_error(status: int, error: str, message: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status,
|
||||
@@ -53,6 +64,10 @@ def _require_auth(authorization: str | None = Header(default=None)) -> None:
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="reuse-surface federation hub", version=HUB_VERSION)
|
||||
store = _store()
|
||||
# Serializes concurrent recompose triggers (manual POST, webhook, future
|
||||
# scheduled fallback) so a burst of pushes coalesces into one compose
|
||||
# pass instead of overlapping ones (T02 design principle 2 debounce).
|
||||
compose_lock = asyncio.Lock()
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
@@ -96,20 +111,34 @@ def create_app() -> FastAPI:
|
||||
raise _http_error(404, "not_found", f"repo not found: {repo}")
|
||||
return Response(status_code=204)
|
||||
|
||||
def _federated_response(
|
||||
async def _federated_response(
|
||||
refresh: bool,
|
||||
accept: str | None,
|
||||
format_param: str,
|
||||
) -> Response:
|
||||
try:
|
||||
federated, warnings = compose_from_store(
|
||||
store, refresh=refresh, cache_dir=_cache_dir(), domain=DEFAULT_DOMAIN
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise _http_error(502, "compose_error", str(exc)) from exc
|
||||
# composed_at/stale track *forced* recomposes (refresh=True: manual
|
||||
# POST, webhook, scheduled fallback), not every plain GET -- a plain
|
||||
# GET still serves current best-effort data (compose_from_store's own
|
||||
# per-source cache_ttl_seconds still applies) but must not silently
|
||||
# clear a staleness signal nothing actually refreshed.
|
||||
async with compose_lock:
|
||||
try:
|
||||
federated, warnings = compose_from_store(
|
||||
store, refresh=refresh, cache_dir=_cache_dir(), domain=DEFAULT_DOMAIN
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise _http_error(502, "compose_error", str(exc)) from exc
|
||||
if refresh:
|
||||
store.record_compose()
|
||||
compose_state = store.get_compose_state()
|
||||
|
||||
federated["composed_at"] = compose_state["composed_at"]
|
||||
federated["stale"] = compose_state["stale"]
|
||||
|
||||
use_yaml = format_param == "yaml" or (accept and "yaml" in accept.lower())
|
||||
headers: dict[str, str] = {}
|
||||
if compose_state["composed_at"]:
|
||||
headers["X-Composed-At"] = compose_state["composed_at"]
|
||||
if warnings:
|
||||
headers["X-Federation-Warnings"] = "; ".join(warnings)
|
||||
if use_yaml:
|
||||
@@ -118,19 +147,58 @@ def create_app() -> FastAPI:
|
||||
return JSONResponse(content=federated, headers=headers)
|
||||
|
||||
@app.get("/v1/federated", response_model=None)
|
||||
def get_federated(
|
||||
async def get_federated(
|
||||
request: Request,
|
||||
refresh: bool = Query(default=False),
|
||||
format: str = Query(default="json"),
|
||||
) -> Response:
|
||||
return _federated_response(refresh, request.headers.get("accept"), format)
|
||||
return await _federated_response(refresh, request.headers.get("accept"), format)
|
||||
|
||||
@app.post("/v1/federated/compose", response_model=None, dependencies=[Depends(_require_auth)])
|
||||
def compose_federated(
|
||||
async def compose_federated(
|
||||
request: Request,
|
||||
format: str = Query(default="json"),
|
||||
) -> Response:
|
||||
return _federated_response(True, request.headers.get("accept"), format)
|
||||
return await _federated_response(True, request.headers.get("accept"), format)
|
||||
|
||||
@app.post("/v1/webhooks/forgejo", response_model=None)
|
||||
async def forgejo_webhook(request: Request) -> Response:
|
||||
body = await request.body()
|
||||
signature = None
|
||||
for header_name in SIGNATURE_HEADERS:
|
||||
signature = request.headers.get(header_name)
|
||||
if signature:
|
||||
break
|
||||
secret = _webhook_secret()
|
||||
if not secret:
|
||||
raise _http_error(
|
||||
503, "misconfigured", "REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET is not configured"
|
||||
)
|
||||
if not verify_signature(secret, body, signature):
|
||||
raise _http_error(401, "unauthorized", "invalid or missing webhook signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise _http_error(400, "validation_error", f"invalid JSON payload: {exc}") from exc
|
||||
|
||||
if not push_touches_registry_index(payload):
|
||||
return JSONResponse(content={"accepted": False, "reason": "no registry/indexes/ change"})
|
||||
|
||||
async with compose_lock:
|
||||
store.mark_stale()
|
||||
try:
|
||||
compose_from_store(
|
||||
store, refresh=True, cache_dir=_cache_dir(), domain=DEFAULT_DOMAIN
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
# Recompose failed -- leave stale=1 so the next GET/scheduled
|
||||
# trigger reports it honestly rather than silently swallowing
|
||||
# the failure.
|
||||
raise _http_error(502, "compose_error", str(exc)) from exc
|
||||
composed_at = store.record_compose()
|
||||
|
||||
return JSONResponse(content={"accepted": True, "composed_at": composed_at})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -61,6 +61,23 @@ class HubStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Single-row table tracking the composed federated index's
|
||||
# freshness (REUSE-WP-0019-T02). composed_at is set whenever a
|
||||
# real compose (refresh=True) completes; stale is set by the
|
||||
# Forgejo webhook receiver when a registry/indexes/ change is
|
||||
# pushed, and cleared on the next successful compose.
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS compose_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
composed_at TEXT,
|
||||
stale INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO compose_state (id, composed_at, stale) VALUES (1, NULL, 0)"
|
||||
)
|
||||
|
||||
def list_repos(self) -> list[dict[str, Any]]:
|
||||
with self._connect() as conn:
|
||||
@@ -160,4 +177,28 @@ class HubStore:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM registrations ORDER BY repo"
|
||||
).fetchall()
|
||||
return [_row_to_registration(row) for row in rows]
|
||||
return [_row_to_registration(row) for row in rows]
|
||||
|
||||
def record_compose(self) -> str:
|
||||
"""Marks the federated index freshly composed (stale cleared).
|
||||
Returns the recorded timestamp."""
|
||||
now = _utc_now()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE compose_state SET composed_at = ?, stale = 0 WHERE id = 1",
|
||||
(now,),
|
||||
)
|
||||
return now
|
||||
|
||||
def mark_stale(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute("UPDATE compose_state SET stale = 1 WHERE id = 1")
|
||||
|
||||
def get_compose_state(self) -> dict[str, Any]:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT composed_at, stale FROM compose_state WHERE id = 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {"composed_at": None, "stale": False}
|
||||
return {"composed_at": row["composed_at"], "stale": bool(row["stale"])}
|
||||
37
reuse_surface/hub/webhooks.py
Normal file
37
reuse_surface/hub/webhooks.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
# Forgejo (Gitea-compatible) signs webhook payloads with HMAC-SHA256 over the
|
||||
# raw request body, sent as a hex digest in X-Forgejo-Signature (Forgejo) or
|
||||
# X-Gitea-Signature (Gitea) -- both forges use the same scheme during the
|
||||
# transition, so both header names are accepted.
|
||||
SIGNATURE_HEADERS = ("X-Forgejo-Signature", "X-Gitea-Signature")
|
||||
|
||||
RELEVANT_PATH_PREFIX = "registry/indexes/"
|
||||
|
||||
|
||||
def verify_signature(secret: str, body: bytes, signature: str | None) -> bool:
|
||||
"""Constant-time HMAC-SHA256 verification. Returns False (never raises)
|
||||
for a missing/malformed signature or a misconfigured (empty) secret --
|
||||
callers must treat False as 'reject', not 'skip verification'."""
|
||||
if not secret or not signature:
|
||||
return False
|
||||
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature.strip())
|
||||
|
||||
|
||||
def push_touches_registry_index(payload: dict[str, Any]) -> bool:
|
||||
"""True if any commit in a Forgejo/Gitea push-event payload adds,
|
||||
modifies, or removes a path under registry/indexes/. Only inspects
|
||||
paths -- never parses file content (design principle 2: no
|
||||
push-parsing of payloads into registry state, the webhook only
|
||||
triggers a pull-based recompose)."""
|
||||
for commit in payload.get("commits") or []:
|
||||
for key in ("added", "modified", "removed"):
|
||||
for path in commit.get(key) or []:
|
||||
if path.startswith(RELEVANT_PATH_PREFIX):
|
||||
return True
|
||||
return False
|
||||
Reference in New Issue
Block a user