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

@@ -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 See `docs/ecosystem-registry-service.md` for the standards comparison and
service direction for registering repos and interacting with the combined 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: Start the first registry service slice with:

67
docs/registry-api.md Normal file
View File

@@ -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
```

View File

@@ -11,6 +11,7 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"jsonschema>=4.18", "jsonschema>=4.18",
"PyYAML>=6.0", "PyYAML>=6.0",
"referencing>=0.30",
] ]
[project.scripts] [project.scripts]

View File

@@ -7,9 +7,8 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import jsonschema from .loader import repo_root
from .schema_validation import draft202012_validator
from .loader import load_yaml, repo_root
class RegistryError(Exception): 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: def _connect(self) -> sqlite3.Connection:
db = sqlite3.connect(self.path) db = sqlite3.connect(self.path)
db.row_factory = sqlite3.Row db.row_factory = sqlite3.Row
@@ -537,19 +560,8 @@ class RegistryStore:
def validate_graph_export(graph: dict[str, Any]) -> None: def validate_graph_export(graph: dict[str, Any]) -> None:
schemas_dir = repo_root() / "schemas" schema_path = repo_root() / "schemas" / "state-hub-export.schema.yaml"
schema_path = schemas_dir / "state-hub-export.schema.yaml" validator = draft202012_validator(schema_path)
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)
errors = sorted(validator.iter_errors(graph), key=lambda error: list(error.path)) errors = sorted(validator.iter_errors(graph), key=lambda error: list(error.path))
if errors: if errors:
error = errors[0] 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) parts = _parts(path)
if path == "/health": if path == "/health":
return HTTPStatus.OK, {"status": "ok"} return HTTPStatus.OK, {"status": "ok"}
if path == "/status":
return HTTPStatus.OK, self.store.status()
if parts == ["repositories"]: if parts == ["repositories"]:
return HTTPStatus.OK, self.store.list_repositories() return HTTPStatus.OK, self.store.list_repositories()
if len(parts) == 3 and parts[0] == "repositories" and parts[2] == "inventory": 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 pathlib import Path
from typing import Any from typing import Any
import jsonschema
from .loader import load_declarations, load_yaml, repo_root from .loader import load_declarations, load_yaml, repo_root
from .model import Declaration, ValidationReport from .model import Declaration, ValidationReport
from .schema_validation import draft202012_validator
SCHEMA_BY_KIND = { SCHEMA_BY_KIND = {
"ServiceDeclaration": "service.schema.yaml", "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: def _validate_schema(root: Path, declarations: list[Declaration], report: ValidationReport) -> None:
schemas_dir = root / "schemas" 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: for declaration in declarations:
schema_name = SCHEMA_BY_KIND.get(declaration.kind) schema_name = SCHEMA_BY_KIND.get(declaration.kind)
@@ -49,13 +44,7 @@ def _validate_schema(root: Path, declarations: list[Declaration], report: Valida
continue continue
schema_path = schemas_dir / schema_name schema_path = schemas_dir / schema_name
schema = load_yaml(schema_path) validator = draft202012_validator(schema_path)
resolver = jsonschema.RefResolver(
base_uri=schema_path.resolve().as_uri(),
referrer=schema,
store=store,
)
validator = jsonschema.Draft202012Validator(schema, resolver=resolver)
for error in sorted(validator.iter_errors(declaration.data), key=lambda e: list(e.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>" location = ".".join(str(part) for part in error.path) or "<root>"
report.add( report.add(

View File

@@ -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: with urllib.request.urlopen(f"{base_url}/health", timeout=5) as response:
assert json.loads(response.read())["status"] == "ok" 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( with urllib.request.urlopen(
f"{base_url}/repositories/railiance-fabric/snapshots", f"{base_url}/repositories/railiance-fabric/snapshots",
timeout=5, timeout=5,
@@ -211,6 +213,8 @@ def test_registry_http_service_serves_queries(tmp_path: Path) -> None:
library_projection_payload = json.loads(response.read()) library_projection_payload = json.loads(response.read())
assert providers_payload[0]["provider_id"] == "railiance-platform.openbao.runtime-secrets" assert providers_payload[0]["provider_id"] == "railiance-platform.openbao.runtime-secrets"
assert snapshots_payload[0]["commit"] == "test-cli-2" 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 drift_payload["graph"]["removed_nodes"]
assert artifact_payload["name"] == "OpenBao KV API" assert artifact_payload["name"] == "OpenBao KV API"
assert artifacts_payload[0]["artifact_type"] == "openapi" assert artifacts_payload[0]["artifact_type"] == "openapi"

View File

@@ -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.