From bc73b05566ea9171ebcbabea239e59bb198a90fa Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 17 May 2026 22:33:21 +0200 Subject: [PATCH] Harden registry API and schema validation --- README.md | 2 +- docs/registry-api.md | 67 +++++++++++++++++++ pyproject.toml | 1 + railiance_fabric/registry.py | 44 +++++++----- railiance_fabric/schema_validation.py | 67 +++++++++++++++++++ railiance_fabric/server.py | 2 + railiance_fabric/validation.py | 15 +---- tests/test_registry.py | 4 ++ .../RAIL-FAB-WP-0005-registry-hardening.md | 60 +++++++++++++++++ 9 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 docs/registry-api.md create mode 100644 railiance_fabric/schema_validation.py create mode 100644 workplans/RAIL-FAB-WP-0005-registry-hardening.md diff --git a/README.md b/README.md index f6eea81..ef1129e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ See `docs/adoption-guide.md` for the declaration workflow and See `docs/ecosystem-registry-service.md` for the standards comparison and service direction for registering repos and interacting with the combined -ecosystem model. +ecosystem model. See `docs/registry-api.md` for the current registry HTTP API. Start the first registry service slice with: diff --git a/docs/registry-api.md b/docs/registry-api.md new file mode 100644 index 0000000..a74e0d4 --- /dev/null +++ b/docs/registry-api.md @@ -0,0 +1,67 @@ +# Registry API + +The Railiance Fabric registry is a small HTTP API over repo-owned Fabric +declarations and supporting inventory. + +## Health And Status + +```text +GET /health +GET /status +``` + +`/health` is intentionally tiny. `/status` includes database path, table counts, +and the latest accepted snapshot per repository. + +## Repository Snapshots + +```text +POST /repositories +GET /repositories +GET /repositories/{repo_slug} +GET /repositories/{repo_slug}/inventory +POST /repositories/{repo_slug}/snapshots +GET /repositories/{repo_slug}/snapshots +GET /repositories/{repo_slug}/snapshots/latest +GET /repositories/{repo_slug}/snapshots/diff +``` + +Snapshot ingestion accepts a `FabricGraphExport` under `graph` plus `commit` +and optional `generated_at`. + +## Graph Queries + +```text +GET /graph/nodes +GET /graph/nodes/{graph_id} +GET /graph/providers?capability_type=runtime-secrets +GET /graph/consumers?target=railiance-platform.openbao.kv-v2 +GET /graph/unresolved +GET /graph/blast-radius?interface_id=openbao-kv-v2-mount +GET /graph/dependency-path?service_id=flex-auth.api +GET /search?q=jsonschema +``` + +## Artifacts And Libraries + +```text +POST /artifacts +GET /artifacts +GET /artifacts/{artifact_id} +GET /libraries +GET /libraries/{library_id} +GET /repositories/{repo_slug}/libraries +POST /repositories/{repo_slug}/libraries/cyclonedx +``` + +CycloneDX ingestion replaces the repo's current library inventory with the +components and services in the submitted SBOM. + +## Exports + +```text +GET /exports/state-hub +GET /exports/backstage +GET /exports/xregistry +GET /exports/libraries/xregistry +``` diff --git a/pyproject.toml b/pyproject.toml index fb9e70a..3df9e77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.12" dependencies = [ "jsonschema>=4.18", "PyYAML>=6.0", + "referencing>=0.30", ] [project.scripts] diff --git a/railiance_fabric/registry.py b/railiance_fabric/registry.py index 136af96..fc284a1 100644 --- a/railiance_fabric/registry.py +++ b/railiance_fabric/registry.py @@ -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] diff --git a/railiance_fabric/schema_validation.py b/railiance_fabric/schema_validation.py new file mode 100644 index 0000000..ddf3799 --- /dev/null +++ b/railiance_fabric/schema_validation.py @@ -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) diff --git a/railiance_fabric/server.py b/railiance_fabric/server.py index f204dfe..a247e17 100644 --- a/railiance_fabric/server.py +++ b/railiance_fabric/server.py @@ -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": diff --git a/railiance_fabric/validation.py b/railiance_fabric/validation.py index 1d5be72..21d7c1f 100644 --- a/railiance_fabric/validation.py +++ b/railiance_fabric/validation.py @@ -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 "" report.add( diff --git a/tests/test_registry.py b/tests/test_registry.py index ce6ff8f..0991602 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -143,6 +143,8 @@ def test_registry_http_service_serves_queries(tmp_path: Path) -> None: with urllib.request.urlopen(f"{base_url}/health", timeout=5) as response: assert json.loads(response.read())["status"] == "ok" + with urllib.request.urlopen(f"{base_url}/status", timeout=5) as response: + status_payload = json.loads(response.read()) with urllib.request.urlopen( f"{base_url}/repositories/railiance-fabric/snapshots", timeout=5, @@ -211,6 +213,8 @@ def test_registry_http_service_serves_queries(tmp_path: Path) -> None: library_projection_payload = json.loads(response.read()) assert providers_payload[0]["provider_id"] == "railiance-platform.openbao.runtime-secrets" assert snapshots_payload[0]["commit"] == "test-cli-2" + assert status_payload["counts"]["repositories"] == 1 + assert status_payload["counts"]["snapshots"] == 3 assert drift_payload["graph"]["removed_nodes"] assert artifact_payload["name"] == "OpenBao KV API" assert artifacts_payload[0]["artifact_type"] == "openapi" diff --git a/workplans/RAIL-FAB-WP-0005-registry-hardening.md b/workplans/RAIL-FAB-WP-0005-registry-hardening.md new file mode 100644 index 0000000..9bc1141 --- /dev/null +++ b/workplans/RAIL-FAB-WP-0005-registry-hardening.md @@ -0,0 +1,60 @@ +--- +id: RAIL-FAB-WP-0005 +type: workplan +title: "Registry Hardening" +domain: railiance +repo: railiance-fabric +status: completed +owner: codex +topic_slug: railiance +planning_priority: medium +planning_order: 5 +state_hub_workstream_id: "75c64655-9062-46a3-a706-78ad09852cd6" +created: "2026-05-17" +updated: "2026-05-17" +--- + +# RAIL-FAB-WP-0005 - Registry Hardening + +## Goal + +Clean up the registry service for continued local use by removing noisy schema +validation warnings, documenting the API contract, and adding a richer +diagnostic endpoint. + +## Tasks + +### T01 - Schema Resolver Cleanup + +```task +id: RAIL-FAB-WP-0005-T01 +status: done +priority: high +state_hub_task_id: "178900ba-a2a4-4f93-a1dd-a626c14e8d66" +``` + +Replace deprecated `jsonschema.RefResolver` usage with the modern referencing +registry API. + +### T02 - Registry Status Endpoint + +```task +id: RAIL-FAB-WP-0005-T02 +status: done +priority: medium +state_hub_task_id: "523f9a35-2285-4b71-915c-9f06698dcc3a" +``` + +Add a registry `/status` endpoint that reports database path, table counts, and +latest accepted snapshots. + +### T03 - API Contract Documentation + +```task +id: RAIL-FAB-WP-0005-T03 +status: done +priority: medium +state_hub_task_id: "d84ebf13-b90b-4296-8987-fc00ba6183ad" +``` + +Document the current registry HTTP API surface in one focused reference file.