generated from coulomb/repo-seed
Harden registry API and schema validation
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
67
docs/registry-api.md
Normal file
67
docs/registry-api.md
Normal 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
|
||||
```
|
||||
@@ -11,6 +11,7 @@ requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"jsonschema>=4.18",
|
||||
"PyYAML>=6.0",
|
||||
"referencing>=0.30",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -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]
|
||||
|
||||
67
railiance_fabric/schema_validation.py
Normal file
67
railiance_fabric/schema_validation.py
Normal 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)
|
||||
@@ -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":
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
60
workplans/RAIL-FAB-WP-0005-registry-hardening.md
Normal file
60
workplans/RAIL-FAB-WP-0005-registry-hardening.md
Normal 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.
|
||||
Reference in New Issue
Block a user