Harden registry API and schema validation

This commit is contained in:
2026-05-17 22:33:21 +02:00
parent 5c20f62fbb
commit bc73b05566
9 changed files with 232 additions and 30 deletions

View File

@@ -7,9 +7,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import jsonschema
from .loader import load_yaml, repo_root
from .loader import repo_root
from .schema_validation import draft202012_validator
class RegistryError(Exception):
@@ -530,6 +529,30 @@ class RegistryStore:
],
}
def status(self) -> dict[str, Any]:
with self._connect() as db:
counts = {
"repositories": db.execute("select count(*) from repositories").fetchone()[0],
"snapshots": db.execute("select count(*) from snapshots").fetchone()[0],
"artifacts": db.execute("select count(*) from artifacts").fetchone()[0],
"libraries": db.execute("select count(*) from libraries").fetchone()[0],
}
latest = [
{
"repo_slug": snapshot["repo_slug"],
"snapshot_id": snapshot["id"],
"commit": snapshot["commit"],
"generated_at": snapshot["generated_at"],
}
for snapshot in self.latest_snapshots()
]
return {
"status": "ok",
"database": str(self.path),
"counts": counts,
"latest_snapshots": latest,
}
def _connect(self) -> sqlite3.Connection:
db = sqlite3.connect(self.path)
db.row_factory = sqlite3.Row
@@ -537,19 +560,8 @@ class RegistryStore:
def validate_graph_export(graph: dict[str, Any]) -> None:
schemas_dir = repo_root() / "schemas"
schema_path = schemas_dir / "state-hub-export.schema.yaml"
store = {
path.resolve().as_uri(): load_yaml(path)
for path in sorted(schemas_dir.glob("*.schema.yaml"))
}
schema = load_yaml(schema_path)
resolver = jsonschema.RefResolver(
base_uri=schema_path.resolve().as_uri(),
referrer=schema,
store=store,
)
validator = jsonschema.Draft202012Validator(schema, resolver=resolver)
schema_path = repo_root() / "schemas" / "state-hub-export.schema.yaml"
validator = draft202012_validator(schema_path)
errors = sorted(validator.iter_errors(graph), key=lambda error: list(error.path))
if errors:
error = errors[0]

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import warnings
from pathlib import Path
from typing import Any
import jsonschema
try:
from referencing import Registry, Resource
except ModuleNotFoundError:
Registry = None
Resource = None
from .loader import load_yaml
def schema_registry(schemas_dir: Path) -> Any:
if Registry is None or Resource is None:
return None
resources: list[tuple[str, Any]] = []
for path in sorted(schemas_dir.glob("*.schema.yaml")):
schema = load_yaml(path)
if not isinstance(schema, dict):
continue
resource = Resource.from_contents(schema)
resources.append((path.resolve().as_uri(), resource))
schema_id = schema.get("$id")
if isinstance(schema_id, str):
resources.append((schema_id, resource))
return Registry().with_resources(resources)
def draft202012_validator(schema_path: Path) -> jsonschema.Draft202012Validator:
schema = load_yaml(schema_path)
if not isinstance(schema, dict):
raise ValueError(f"schema must be a mapping: {schema_path}")
registry = schema_registry(schema_path.parent)
if registry is None:
return _legacy_validator(schema_path, schema)
return jsonschema.Draft202012Validator(
schema,
registry=registry,
)
def _legacy_validator(
schema_path: Path,
schema: dict[str, Any],
) -> jsonschema.Draft202012Validator:
store: dict[str, Any] = {}
for path in sorted(schema_path.parent.glob("*.schema.yaml")):
loaded_schema = load_yaml(path)
store[path.resolve().as_uri()] = loaded_schema
if isinstance(loaded_schema, dict):
schema_id = loaded_schema.get("$id")
if isinstance(schema_id, str):
store[schema_id] = loaded_schema
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
resolver = jsonschema.RefResolver(
base_uri=schema_path.resolve().as_uri(),
referrer=schema,
store=store,
)
return jsonschema.Draft202012Validator(schema, resolver=resolver)

View File

@@ -39,6 +39,8 @@ class RegistryHandler(BaseHTTPRequestHandler):
parts = _parts(path)
if path == "/health":
return HTTPStatus.OK, {"status": "ok"}
if path == "/status":
return HTTPStatus.OK, self.store.status()
if parts == ["repositories"]:
return HTTPStatus.OK, self.store.list_repositories()
if len(parts) == 3 and parts[0] == "repositories" and parts[2] == "inventory":

View File

@@ -4,10 +4,9 @@ from collections import defaultdict
from pathlib import Path
from typing import Any
import jsonschema
from .loader import load_declarations, load_yaml, repo_root
from .model import Declaration, ValidationReport
from .schema_validation import draft202012_validator
SCHEMA_BY_KIND = {
"ServiceDeclaration": "service.schema.yaml",
@@ -32,10 +31,6 @@ def validate_roots(paths: list[Path]) -> ValidationReport:
def _validate_schema(root: Path, declarations: list[Declaration], report: ValidationReport) -> None:
schemas_dir = root / "schemas"
store = {
path.resolve().as_uri(): load_yaml(path)
for path in sorted(schemas_dir.glob("*.schema.yaml"))
}
for declaration in declarations:
schema_name = SCHEMA_BY_KIND.get(declaration.kind)
@@ -49,13 +44,7 @@ def _validate_schema(root: Path, declarations: list[Declaration], report: Valida
continue
schema_path = schemas_dir / schema_name
schema = load_yaml(schema_path)
resolver = jsonschema.RefResolver(
base_uri=schema_path.resolve().as_uri(),
referrer=schema,
store=store,
)
validator = jsonschema.Draft202012Validator(schema, resolver=resolver)
validator = draft202012_validator(schema_path)
for error in sorted(validator.iter_errors(declaration.data), key=lambda e: list(e.path)):
location = ".".join(str(part) for part in error.path) or "<root>"
report.add(