generated from coulomb/repo-seed
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>
204 lines
7.4 KiB
Python
204 lines
7.4 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
from jsonschema import Draft202012Validator
|
|
|
|
SCHEMA_PATH = Path(__file__).resolve().parent / "hub-registration.schema.yaml"
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
|
|
|
|
def _load_schema() -> dict[str, Any]:
|
|
return yaml.safe_load(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _validate(payload: dict[str, Any], schema_ref: str) -> list[str]:
|
|
schema = _load_schema()
|
|
subschema = schema["$defs"][schema_ref]
|
|
validator = Draft202012Validator(subschema)
|
|
return [error.message for error in validator.iter_errors(payload)]
|
|
|
|
|
|
def _row_to_registration(row: sqlite3.Row) -> dict[str, Any]:
|
|
data = json.loads(row["payload"])
|
|
data["registered_at"] = row["registered_at"]
|
|
data["updated_at"] = row["updated_at"]
|
|
return data
|
|
|
|
|
|
def _public_registration(registration: dict[str, Any]) -> dict[str, Any]:
|
|
return {key: value for key, value in registration.items() if key != "auth_env"}
|
|
|
|
|
|
class HubStore:
|
|
def __init__(self, db_path: Path) -> None:
|
|
self.db_path = db_path
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._init_db()
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def _init_db(self) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS registrations (
|
|
repo TEXT PRIMARY KEY,
|
|
payload TEXT NOT NULL,
|
|
registered_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
# 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:
|
|
rows = conn.execute(
|
|
"SELECT * FROM registrations ORDER BY repo"
|
|
).fetchall()
|
|
return [_public_registration(_row_to_registration(row)) for row in rows]
|
|
|
|
def get_repo(self, repo: str) -> dict[str, Any] | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM registrations WHERE repo = ?", (repo,)
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return _public_registration(_row_to_registration(row))
|
|
|
|
def get_repo_internal(self, repo: str) -> dict[str, Any] | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM registrations WHERE repo = ?", (repo,)
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return _row_to_registration(row)
|
|
|
|
def create_repo(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
errors = _validate(payload, "registration_request")
|
|
if errors:
|
|
raise ValueError("; ".join(errors))
|
|
now = _utc_now()
|
|
record: dict[str, Any] = {
|
|
"repo": payload["repo"],
|
|
"url": payload["url"],
|
|
"enabled": payload.get("enabled", True),
|
|
"required": payload.get("required", False),
|
|
"domain": payload["domain"],
|
|
"cache_ttl_seconds": payload.get("cache_ttl_seconds", 86400),
|
|
"auth_header": payload.get("auth_header", "Authorization"),
|
|
}
|
|
for optional in ("description", "auth_env", "registered_by"):
|
|
if payload.get(optional) is not None:
|
|
record[optional] = payload[optional]
|
|
validator = Draft202012Validator(_load_schema())
|
|
full_errors = [
|
|
error.message
|
|
for error in validator.iter_errors(
|
|
{**record, "registered_at": now, "updated_at": now}
|
|
)
|
|
]
|
|
if full_errors:
|
|
raise ValueError("; ".join(full_errors))
|
|
|
|
with self._connect() as conn:
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO registrations (repo, payload, registered_at, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
""",
|
|
(record["repo"], json.dumps(record), now, now),
|
|
)
|
|
except sqlite3.IntegrityError as exc:
|
|
raise FileExistsError(f"repo already registered: {record['repo']}") from exc
|
|
return _public_registration({**record, "registered_at": now, "updated_at": now})
|
|
|
|
def update_repo(self, repo: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
errors = _validate(payload, "registration_update")
|
|
if errors:
|
|
raise ValueError("; ".join(errors))
|
|
existing = self.get_repo_internal(repo)
|
|
if existing is None:
|
|
raise KeyError(f"repo not found: {repo}")
|
|
updated = {**existing, **{k: v for k, v in payload.items() if v is not None}, "repo": repo}
|
|
now = _utc_now()
|
|
updated["updated_at"] = now
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
UPDATE registrations
|
|
SET payload = ?, updated_at = ?
|
|
WHERE repo = ?
|
|
""",
|
|
(json.dumps(updated), now, repo),
|
|
)
|
|
return _public_registration(updated)
|
|
|
|
def delete_repo(self, repo: str) -> bool:
|
|
with self._connect() as conn:
|
|
cursor = conn.execute(
|
|
"DELETE FROM registrations WHERE repo = ?", (repo,)
|
|
)
|
|
return cursor.rowcount > 0
|
|
|
|
def list_repos_internal(self) -> list[dict[str, Any]]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM registrations ORDER BY repo"
|
|
).fetchall()
|
|
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"])} |