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
|
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
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 = [
|
dependencies = [
|
||||||
"jsonschema>=4.18",
|
"jsonschema>=4.18",
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
|
"referencing>=0.30",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
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)
|
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":
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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