reuse-surface/reuse_surface/hub/store.py
tegwick d181043717
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 10s
ci / validate-registry (push) Has been cancelled
Build and Publish Container Image / build-and-push (push) Successful in 1m16s
REUSE-WP-0019-T04: reuse telemetry store and recording
Implements the hub side of the shared reuse-event schema (already drafted
in WP-0018-T01, schemas/reuse-event.schema.json): a SQLite reuse_events
table, POST /v1/reuse-events (token-auth), GET /v1/reuse-events?capability_id=
(read-only).

reuse_surface/plan_check.py: refactored record_outcome around a new
shared post_or_fallback_reuse_event() helper -- tries the hub first, falls
back to the local JSONL only on failure/unreachability, never both. New
record_manual_reuse_event() backs a new CLI command, reuse-surface
record-reuse, for retroactive facts recorded outside plan-check.

Privacy/scope (repo slugs and capability ids only, no code, no secrets) is
enforced structurally via the schema's additionalProperties: false, not
just by convention.

21 new pytest cases, 145 total pass. Live-verified against a real running
hub instance: POST/GET /v1/reuse-events directly, record-reuse and
plan-check --record-outcome both posting successfully to the hub, and --
after actually killing the hub process -- confirmed the fallback path
writes correctly to the local JSONL instead of erroring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 22:32:19 +02:00

270 lines
No EOL
10 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"
REUSE_EVENT_SCHEMA_PATH = Path(__file__).resolve().parent.parent.parent / "schemas" / "reuse-event.schema.json"
def _load_reuse_event_schema() -> dict[str, Any]:
return json.loads(REUSE_EVENT_SCHEMA_PATH.read_text(encoding="utf-8"))
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)"
)
# Reuse telemetry (REUSE-WP-0019-T04): append-only facts, never
# hand-edited. Schema shared with the local JSONL fallback in
# plan_check.py's record_outcome() -- see schemas/reuse-event.schema.json.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS reuse_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
consumer_repo TEXT NOT NULL,
capability_id TEXT,
verdict TEXT NOT NULL,
outcome TEXT,
source TEXT NOT NULL
)
"""
)
def record_reuse_event(self, payload: dict[str, Any]) -> dict[str, Any]:
errors = [
error.message
for error in Draft202012Validator(_load_reuse_event_schema()).iter_errors(payload)
]
if errors:
raise ValueError("; ".join(errors))
with self._connect() as conn:
conn.execute(
"""
INSERT INTO reuse_events (ts, consumer_repo, capability_id, verdict, outcome, source)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
payload["ts"],
payload["consumer_repo"],
payload.get("capability_id"),
payload["verdict"],
payload.get("outcome"),
payload["source"],
),
)
return payload
def list_reuse_events(self, capability_id: str | None = None) -> list[dict[str, Any]]:
query = "SELECT ts, consumer_repo, capability_id, verdict, outcome, source FROM reuse_events"
params: tuple[Any, ...] = ()
if capability_id:
query += " WHERE capability_id = ?"
params = (capability_id,)
query += " ORDER BY ts"
with self._connect() as conn:
rows = conn.execute(query, params).fetchall()
return [
{
"ts": row["ts"],
"consumer_repo": row["consumer_repo"],
"capability_id": row["capability_id"],
"verdict": row["verdict"],
"outcome": row["outcome"],
"source": row["source"],
}
for row in rows
]
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"])}