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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user