generated from coulomb/repo-seed
Implement WP-0011 hub service, CLI, and deployment artifacts
Some checks failed
ci / validate-registry (push) Has been cancelled
Some checks failed
ci / validate-registry (push) Has been cancelled
Add FederationHubAPI spec, hub registration schema, FastAPI hub with SQLite persistence, reuse-surface hub CLI client, Dockerfile, and hub tests. Activate workplan; T05 deploy and T06 ops docs remain open pending railiance01 cutover.
This commit is contained in:
165
reuse_surface/hub/store.py
Normal file
165
reuse_surface/hub/store.py
Normal file
@@ -0,0 +1,165 @@
|
||||
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
|
||||
|
||||
from reuse_surface.registry import ROOT
|
||||
|
||||
SCHEMA_PATH = ROOT / "schemas" / "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
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
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]
|
||||
Reference in New Issue
Block a user