Start ecosystem registry service

This commit is contained in:
2026-05-17 20:06:44 +02:00
parent 19f9fddc35
commit 2523eecce2
9 changed files with 817 additions and 9 deletions

View File

@@ -49,3 +49,13 @@ 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.
Start the first registry service slice with:
```bash
railiance-fabric-registry --db .railiance-fabric/registry.sqlite3 --port 8765
```
The initial service exposes repository registration, graph snapshot ingestion,
graph query endpoints, and a State Hub export endpoint. It stores snapshots in
SQLite and reuses the Fabric graph export shape.

View File

@@ -184,6 +184,34 @@ stable, the backing store can be replaced or expanded.
7. CycloneDX SBOM attachment for library/package inventory.
8. CloudEvents-style registry events once mutation endpoints exist.
## First Service Slice
The initial implementation is deliberately dependency-light:
- `railiance_fabric.registry` owns SQLite persistence, snapshot validation, and
graph query helpers.
- `railiance_fabric.server` exposes a stdlib HTTP service.
- `railiance-fabric-registry` starts the service.
The first endpoint set is:
```text
GET /health
POST /repositories
GET /repositories
GET /repositories/{repo_slug}
POST /repositories/{repo_slug}/snapshots
GET /repositories/{repo_slug}/snapshots/latest
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 /exports/state-hub
```
## Open Design Questions
- Should the registry pull repos itself, or should repos/agents push validated

View File

@@ -15,6 +15,7 @@ dependencies = [
[project.scripts]
railiance-fabric = "railiance_fabric.cli:main"
railiance-fabric-registry = "railiance_fabric.server:main"
[tool.setuptools.packages.find]
include = ["railiance_fabric*"]

View File

@@ -194,6 +194,7 @@ class FabricGraph:
"repo": declaration.metadata.get("repo", ""),
"domain": declaration.metadata.get("domain", ""),
"lifecycle": declaration.spec.get("lifecycle", ""),
"attributes": _export_attributes(declaration),
}
)
@@ -262,3 +263,46 @@ def _mermaid_id(value: str) -> str:
def _escape_mermaid(value: str) -> str:
return value.replace('"', '\\"')
def _export_attributes(declaration: Declaration) -> dict[str, Any]:
spec = declaration.spec
if declaration.kind == "ServiceDeclaration":
return {
"provides_capabilities": list(spec.get("provides_capabilities", [])),
"exposes_interfaces": list(spec.get("exposes_interfaces", [])),
}
if declaration.kind == "CapabilityDeclaration":
return {
"capability_type": spec.get("capability_type", ""),
"service_id": spec.get("service_id", ""),
"interface_ids": list(spec.get("interface_ids", [])),
"environments": list(spec.get("environments", [])),
}
if declaration.kind == "InterfaceDeclaration":
return {
"interface_type": spec.get("interface_type", ""),
"service_id": spec.get("service_id", ""),
"capability_ids": list(spec.get("capability_ids", [])),
"version": spec.get("version", ""),
"auth": spec.get("auth", ""),
}
if declaration.kind == "DependencyDeclaration":
requires = spec.get("requires", {})
interface = spec.get("interface", {})
return {
"consumer_service_id": spec.get("consumer_service_id", ""),
"requires_capability_id": requires.get("capability_id", ""),
"requires_capability_type": requires.get("capability_type", ""),
"interface_type": interface.get("type", ""),
"environments": list(spec.get("environments", [])),
"criticality": spec.get("criticality", ""),
}
if declaration.kind == "BindingAssertion":
return {
"dependency_id": spec.get("dependency_id", ""),
"provider_capability_id": spec.get("provider_capability_id", ""),
"provider_interface_id": spec.get("provider_interface_id", ""),
"status": spec.get("status", ""),
}
return {}

View File

@@ -0,0 +1,482 @@
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import jsonschema
from .loader import load_yaml, repo_root
class RegistryError(Exception):
def __init__(self, message: str, status_code: int = 400) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
@dataclass(frozen=True)
class RegistryStore:
path: Path
def init_schema(self) -> None:
if str(self.path) != ":memory:":
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as db:
db.executescript(
"""
create table if not exists repositories (
slug text primary key,
name text not null,
remote_url text,
default_branch text,
state_hub_repo_id text,
created_at text not null,
updated_at text not null
);
create table if not exists snapshots (
id integer primary key autoincrement,
repo_slug text not null references repositories(slug),
commit_sha text not null,
generated_at text not null,
graph_json text not null,
created_at text not null
);
create index if not exists idx_snapshots_repo_latest
on snapshots(repo_slug, id desc);
"""
)
def upsert_repository(self, payload: dict[str, Any]) -> dict[str, Any]:
slug = _required_text(payload, "slug")
now = _utc_now()
name = str(payload.get("name") or slug)
remote_url = _optional_text(payload, "remote_url")
default_branch = str(payload.get("default_branch") or "main")
state_hub_repo_id = _optional_text(payload, "state_hub_repo_id")
with self._connect() as db:
db.execute(
"""
insert into repositories (
slug, name, remote_url, default_branch, state_hub_repo_id,
created_at, updated_at
)
values (?, ?, ?, ?, ?, ?, ?)
on conflict(slug) do update set
name = excluded.name,
remote_url = excluded.remote_url,
default_branch = excluded.default_branch,
state_hub_repo_id = excluded.state_hub_repo_id,
updated_at = excluded.updated_at
""",
(slug, name, remote_url, default_branch, state_hub_repo_id, now, now),
)
return self.get_repository(slug)
def list_repositories(self) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"""
select slug, name, remote_url, default_branch, state_hub_repo_id,
created_at, updated_at
from repositories
order by slug
"""
).fetchall()
return [_row_dict(row) for row in rows]
def get_repository(self, slug: str) -> dict[str, Any]:
with self._connect() as db:
row = db.execute(
"""
select slug, name, remote_url, default_branch, state_hub_repo_id,
created_at, updated_at
from repositories
where slug = ?
""",
(slug,),
).fetchone()
if row is None:
raise RegistryError(f"repository not found: {slug}", 404)
return _row_dict(row)
def add_snapshot(self, repo_slug: str, payload: dict[str, Any]) -> dict[str, Any]:
self.get_repository(repo_slug)
commit = _required_text(payload, "commit")
generated_at = str(payload.get("generated_at") or _utc_now())
graph = payload.get("graph")
if not isinstance(graph, dict):
raise RegistryError("snapshot payload requires object field 'graph'")
graph = _with_source(graph, repo_slug, commit, generated_at)
validate_graph_export(graph)
now = _utc_now()
with self._connect() as db:
cursor = db.execute(
"""
insert into snapshots (repo_slug, commit_sha, generated_at, graph_json, created_at)
values (?, ?, ?, ?, ?)
""",
(repo_slug, commit, generated_at, json.dumps(graph, sort_keys=True), now),
)
snapshot_id = int(cursor.lastrowid)
return self.get_snapshot(snapshot_id)
def get_snapshot(self, snapshot_id: int) -> dict[str, Any]:
with self._connect() as db:
row = db.execute(
"""
select id, repo_slug, commit_sha, generated_at, graph_json, created_at
from snapshots
where id = ?
""",
(snapshot_id,),
).fetchone()
if row is None:
raise RegistryError(f"snapshot not found: {snapshot_id}", 404)
return _snapshot_dict(row)
def latest_snapshot(self, repo_slug: str) -> dict[str, Any]:
self.get_repository(repo_slug)
with self._connect() as db:
row = db.execute(
"""
select id, repo_slug, commit_sha, generated_at, graph_json, created_at
from snapshots
where repo_slug = ?
order by id desc
limit 1
""",
(repo_slug,),
).fetchone()
if row is None:
raise RegistryError(f"no snapshots for repository: {repo_slug}", 404)
return _snapshot_dict(row)
def latest_snapshots(self) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"""
select s.id, s.repo_slug, s.commit_sha, s.generated_at, s.graph_json, s.created_at
from snapshots s
join (
select repo_slug, max(id) as latest_id
from snapshots
group by repo_slug
) latest on latest.latest_id = s.id
order by s.repo_slug
"""
).fetchall()
return [_snapshot_dict(row) for row in rows]
def combined_graph(self) -> dict[str, Any]:
nodes: dict[str, dict[str, Any]] = {}
edges: list[dict[str, str]] = []
for snapshot in self.latest_snapshots():
graph = snapshot["graph"]
for node in graph.get("nodes", []):
if isinstance(node, dict):
nodes[str(node.get("id", ""))] = node
for edge in graph.get("edges", []):
if isinstance(edge, dict):
edges.append(
{
"from": str(edge.get("from", "")),
"to": str(edge.get("to", "")),
"type": str(edge.get("type", "")),
}
)
return {
"apiVersion": "railiance.fabric/v1alpha1",
"kind": "FabricGraphExport",
"generated_at": _utc_now(),
"source": {"repo": "registry", "commit": "", "path": ""},
"nodes": [nodes[key] for key in sorted(nodes)],
"edges": sorted(edges, key=lambda edge: (edge["from"], edge["to"], edge["type"])),
}
def _connect(self) -> sqlite3.Connection:
db = sqlite3.connect(self.path)
db.row_factory = sqlite3.Row
return db
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)
errors = sorted(validator.iter_errors(graph), key=lambda error: list(error.path))
if errors:
error = errors[0]
location = ".".join(str(part) for part in error.path) or "<root>"
raise RegistryError(f"invalid FabricGraphExport at {location}: {error.message}")
def providers(graph: dict[str, Any], capability: str) -> list[dict[str, Any]]:
result = []
for node in _nodes(graph):
attrs = _attrs(node)
if node.get("kind") != "CapabilityDeclaration":
continue
if node.get("id") == capability or attrs.get("capability_type") == capability:
result.append(
{
"provider_id": node.get("id", ""),
"name": node.get("name", ""),
"service_id": attrs.get("service_id", ""),
"capability_type": attrs.get("capability_type", ""),
"lifecycle": node.get("lifecycle", ""),
"interfaces": attrs.get("interface_ids", []),
"repo": node.get("repo", ""),
"domain": node.get("domain", ""),
}
)
return sorted(result, key=lambda item: item["provider_id"])
def consumers(graph: dict[str, Any], target: str) -> list[dict[str, Any]]:
nodes = _nodes_by_id(graph)
target_interface_type = ""
target_node = nodes.get(target)
if target_node and target_node.get("kind") == "InterfaceDeclaration":
target_interface_type = str(_attrs(target_node).get("interface_type", ""))
result: list[dict[str, Any]] = []
bindings_by_dependency = _bindings_by_dependency(graph)
for dependency in _dependency_nodes(graph):
attrs = _attrs(dependency)
dependency_id = str(dependency.get("id", ""))
dependency_matches = target in {
dependency_id,
str(attrs.get("requires_capability_id", "")),
str(attrs.get("requires_capability_type", "")),
str(attrs.get("interface_type", "")),
} or bool(target_interface_type and target_interface_type == attrs.get("interface_type"))
bindings = bindings_by_dependency.get(dependency_id, [])
matching_bindings = [
binding
for binding in bindings
if target in {binding["provider_capability_id"], binding["provider_interface_id"]}
]
if dependency_matches and not bindings:
result.append(_consumer_match(attrs, dependency_id, {}))
for binding in bindings:
if dependency_matches or binding in matching_bindings:
result.append(_consumer_match(attrs, dependency_id, binding))
return sorted(result, key=lambda item: (item["consumer_service_id"], item["dependency_id"]))
def unresolved_dependencies(graph: dict[str, Any]) -> list[dict[str, Any]]:
result = []
bindings_by_dependency = _bindings_by_dependency(graph)
for dependency in _dependency_nodes(graph):
attrs = _attrs(dependency)
dependency_id = str(dependency.get("id", ""))
required_id = str(attrs.get("requires_capability_id", ""))
required_type = str(attrs.get("requires_capability_type", ""))
provider_matches = providers(graph, required_id or required_type)
bindings = bindings_by_dependency.get(dependency_id, [])
has_missing_binding = any(binding.get("status") in {"missing", "disputed"} for binding in bindings)
if not provider_matches or has_missing_binding:
result.append(
{
"dependency_id": dependency_id,
"consumer_service_id": attrs.get("consumer_service_id", ""),
"requires_capability_id": required_id,
"requires_capability_type": required_type,
"interface_type": attrs.get("interface_type", ""),
"reason": "missing_provider" if not provider_matches else "binding_not_exact",
}
)
return sorted(result, key=lambda item: item["dependency_id"])
def blast_radius(graph: dict[str, Any], interface: str) -> list[dict[str, Any]]:
target_node = _nodes_by_id(graph).get(interface)
matches = consumers(graph, interface)
if target_node and target_node.get("kind") == "InterfaceDeclaration":
return [match for match in matches if match.get("provider_interface_id") == interface]
return [
match
for match in matches
if _dependency_attrs(graph, match["dependency_id"]).get("interface_type") == interface
]
def dependency_path_lines(graph: dict[str, Any], service_id: str) -> list[str]:
nodes = _nodes_by_id(graph)
if service_id not in nodes or nodes[service_id].get("kind") != "ServiceDeclaration":
return [f"unknown service: {service_id}"]
deps_by_consumer: dict[str, list[dict[str, Any]]] = {}
for dependency in _dependency_nodes(graph):
attrs = _attrs(dependency)
deps_by_consumer.setdefault(str(attrs.get("consumer_service_id", "")), []).append(dependency)
capability_service = {
str(node.get("id", "")): str(_attrs(node).get("service_id", ""))
for node in _nodes(graph)
if node.get("kind") == "CapabilityDeclaration"
}
bindings_by_dependency = _bindings_by_dependency(graph)
lines: list[str] = []
def walk(current: str, indent: int, stack: list[str]) -> None:
prefix = " " * indent
if current in stack:
lines.append(f"{prefix}{current} (cycle)")
return
lines.append(f"{prefix}{current}")
dependencies = sorted(deps_by_consumer.get(current, []), key=lambda item: str(item.get("id", "")))
if not dependencies:
lines.append(f"{prefix} no declared dependencies")
return
for dependency in dependencies:
dependency_id = str(dependency.get("id", ""))
attrs = _attrs(dependency)
required = attrs.get("requires_capability_type", "")
lines.append(f"{prefix} requires {required}: {dependency_id}")
bindings = bindings_by_dependency.get(dependency_id, [])
if not bindings:
candidate_providers = providers(graph, str(required))
if candidate_providers:
for provider in candidate_providers:
lines.append(f"{prefix} candidate {provider['provider_id']}")
else:
lines.append(f"{prefix} unresolved")
continue
for binding in bindings:
provider_id = binding.get("provider_capability_id", "")
provider_service = capability_service.get(provider_id, "")
status = binding.get("status", "")
lines.append(f"{prefix} {status} -> {provider_id}")
if provider_service and provider_service != current:
walk(provider_service, indent + 3, stack + [current])
walk(service_id, 0, [])
return lines
def graph_node(graph: dict[str, Any], graph_id: str) -> dict[str, Any]:
node = _nodes_by_id(graph).get(graph_id)
if node is None:
raise RegistryError(f"graph node not found: {graph_id}", 404)
return node
def _with_source(graph: dict[str, Any], repo_slug: str, commit: str, generated_at: str) -> dict[str, Any]:
copy = json.loads(json.dumps(graph))
copy.setdefault("generated_at", generated_at)
copy.setdefault("source", {})
copy["source"].setdefault("repo", repo_slug)
copy["source"].setdefault("commit", commit)
copy["source"].setdefault("path", "")
return copy
def _snapshot_dict(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"repo_slug": row["repo_slug"],
"commit": row["commit_sha"],
"generated_at": row["generated_at"],
"graph": json.loads(row["graph_json"]),
"created_at": row["created_at"],
}
def _row_dict(row: sqlite3.Row) -> dict[str, Any]:
return {key: row[key] for key in row.keys()}
def _required_text(payload: dict[str, Any], key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value.strip():
raise RegistryError(f"field '{key}' is required")
return value.strip()
def _optional_text(payload: dict[str, Any], key: str) -> str | None:
value = payload.get(key)
if value is None:
return None
if not isinstance(value, str):
raise RegistryError(f"field '{key}' must be a string")
return value
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _nodes(graph: dict[str, Any]) -> list[dict[str, Any]]:
return [node for node in graph.get("nodes", []) if isinstance(node, dict)]
def _nodes_by_id(graph: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {str(node.get("id", "")): node for node in _nodes(graph)}
def _attrs(node: dict[str, Any]) -> dict[str, Any]:
attrs = node.get("attributes", {})
return attrs if isinstance(attrs, dict) else {}
def _dependency_nodes(graph: dict[str, Any]) -> list[dict[str, Any]]:
return [node for node in _nodes(graph) if node.get("kind") == "DependencyDeclaration"]
def _dependency_attrs(graph: dict[str, Any], dependency_id: str) -> dict[str, Any]:
node = _nodes_by_id(graph).get(dependency_id, {})
return _attrs(node)
def _bindings_by_dependency(graph: dict[str, Any]) -> dict[str, list[dict[str, str]]]:
result: dict[str, list[dict[str, str]]] = {}
for node in _nodes(graph):
if node.get("kind") != "BindingAssertion":
continue
attrs = _attrs(node)
dependency_id = str(attrs.get("dependency_id", ""))
if not dependency_id:
continue
result.setdefault(dependency_id, []).append(
{
"binding_id": str(node.get("id", "")),
"provider_capability_id": str(attrs.get("provider_capability_id", "")),
"provider_interface_id": str(attrs.get("provider_interface_id", "")),
"status": str(attrs.get("status", "")),
}
)
for bindings in result.values():
bindings.sort(key=lambda item: item["binding_id"])
return result
def _consumer_match(attrs: dict[str, Any], dependency_id: str, binding: dict[str, str]) -> dict[str, Any]:
return {
"consumer_service_id": attrs.get("consumer_service_id", ""),
"dependency_id": dependency_id,
"required_capability_type": attrs.get("requires_capability_type", ""),
"provider_capability_id": binding.get("provider_capability_id", ""),
"provider_interface_id": binding.get("provider_interface_id", ""),
"status": binding.get("status", ""),
}

149
railiance_fabric/server.py Normal file
View File

@@ -0,0 +1,149 @@
from __future__ import annotations
import argparse
import json
import sys
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from .registry import (
RegistryError,
RegistryStore,
blast_radius,
consumers,
dependency_path_lines,
graph_node,
providers,
unresolved_dependencies,
)
class RegistryHandler(BaseHTTPRequestHandler):
store: RegistryStore
def do_GET(self) -> None:
self._handle(self._get)
def do_POST(self) -> None:
self._handle(self._post)
def log_message(self, format: str, *args: object) -> None:
return
def _get(self, path: str, query: dict[str, list[str]]) -> tuple[int, Any]:
parts = _parts(path)
if path == "/health":
return HTTPStatus.OK, {"status": "ok"}
if parts == ["repositories"]:
return HTTPStatus.OK, self.store.list_repositories()
if len(parts) == 2 and parts[0] == "repositories":
return HTTPStatus.OK, self.store.get_repository(parts[1])
if len(parts) == 4 and parts[0] == "repositories" and parts[2] == "snapshots" and parts[3] == "latest":
return HTTPStatus.OK, self.store.latest_snapshot(parts[1])
if parts == ["graph", "nodes"]:
return HTTPStatus.OK, self.store.combined_graph()["nodes"]
if len(parts) == 3 and parts[0] == "graph" and parts[1] == "nodes":
return HTTPStatus.OK, graph_node(self.store.combined_graph(), parts[2])
if parts == ["graph", "providers"]:
return HTTPStatus.OK, providers(self.store.combined_graph(), _query_one(query, "capability_type"))
if parts == ["graph", "consumers"]:
return HTTPStatus.OK, consumers(self.store.combined_graph(), _query_one(query, "target"))
if parts == ["graph", "unresolved"]:
return HTTPStatus.OK, unresolved_dependencies(self.store.combined_graph())
if parts == ["graph", "blast-radius"]:
return HTTPStatus.OK, blast_radius(self.store.combined_graph(), _query_one(query, "interface_id"))
if parts == ["graph", "dependency-path"]:
return HTTPStatus.OK, {"lines": dependency_path_lines(self.store.combined_graph(), _query_one(query, "service_id"))}
if parts == ["exports", "state-hub"]:
return HTTPStatus.OK, self.store.combined_graph()
raise RegistryError(f"route not found: {path}", 404)
def _post(self, path: str, _query: dict[str, list[str]]) -> tuple[int, Any]:
parts = _parts(path)
body = self._read_json()
if parts == ["repositories"]:
return HTTPStatus.CREATED, self.store.upsert_repository(body)
if len(parts) == 3 and parts[0] == "repositories" and parts[2] == "snapshots":
return HTTPStatus.CREATED, self.store.add_snapshot(parts[1], body)
raise RegistryError(f"route not found: {path}", 404)
def _handle(self, action: Any) -> None:
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
try:
status, body = action(parsed.path, query)
self._send_json(int(status), body)
except RegistryError as exc:
self._send_json(exc.status_code, {"error": exc.message})
except json.JSONDecodeError as exc:
self._send_json(400, {"error": f"invalid JSON request body: {exc}"})
except Exception as exc:
self._send_json(500, {"error": str(exc)})
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
if length == 0:
return {}
raw = self.rfile.read(length)
body = json.loads(raw.decode("utf-8"))
if not isinstance(body, dict):
raise RegistryError("request body must be a JSON object")
return body
def _send_json(self, status: int, body: Any) -> None:
payload = json.dumps(body, indent=2, sort_keys=True).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="railiance-fabric-registry",
description="Run the Railiance Fabric ecosystem registry service.",
)
parser.add_argument("--db", type=Path, default=Path(".railiance-fabric/registry.sqlite3"))
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
store = RegistryStore(args.db)
store.init_schema()
class Handler(RegistryHandler):
pass
Handler.store = store
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"Railiance Fabric Registry listening on http://{args.host}:{args.port}")
try:
server.serve_forever()
except KeyboardInterrupt:
print()
return 0
finally:
server.server_close()
return 0
def _parts(path: str) -> list[str]:
return [part for part in path.strip("/").split("/") if part]
def _query_one(query: dict[str, list[str]], key: str) -> str:
values = query.get(key)
if not values or not values[0]:
raise RegistryError(f"missing query parameter: {key}")
return values[0]
if __name__ == "__main__":
sys.exit(main())

View File

@@ -53,6 +53,9 @@ properties:
type: string
lifecycle:
type: string
attributes:
type: object
additionalProperties: true
edges:
type: array
items:

82
tests/test_registry.py Normal file
View File

@@ -0,0 +1,82 @@
from __future__ import annotations
import json
import threading
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from railiance_fabric.graph import build_graph
from railiance_fabric.registry import (
RegistryStore,
blast_radius,
consumers,
providers,
unresolved_dependencies,
)
from railiance_fabric.server import RegistryHandler
def test_registry_accepts_snapshot_and_queries_graph(tmp_path: Path) -> None:
store = RegistryStore(tmp_path / "registry.sqlite3")
store.init_schema()
store.upsert_repository(
{
"slug": "railiance-fabric",
"name": "Railiance Fabric",
"remote_url": "https://example.invalid/railiance-fabric.git",
}
)
graph = build_graph([Path(".")])
snapshot = store.add_snapshot(
"railiance-fabric",
{
"commit": "test-commit",
"generated_at": "2026-05-17T00:00:00Z",
"graph": graph.to_export(),
},
)
combined = store.combined_graph()
assert snapshot["repo_slug"] == "railiance-fabric"
assert providers(combined, "runtime-secrets")[0]["provider_id"] == "railiance-platform.openbao.runtime-secrets"
assert {match["status"] for match in consumers(combined, "railiance-platform.openbao.kv-v2")} >= {"exact"}
assert unresolved_dependencies(combined) == []
assert blast_radius(combined, "openbao-kv-v2-mount")
def test_registry_http_service_serves_queries(tmp_path: Path) -> None:
store = RegistryStore(tmp_path / "registry.sqlite3")
store.init_schema()
store.upsert_repository({"slug": "railiance-fabric", "name": "Railiance Fabric"})
store.add_snapshot(
"railiance-fabric",
{
"commit": "test-commit",
"generated_at": "2026-05-17T00:00:00Z",
"graph": build_graph([Path(".")]).to_export(),
},
)
class Handler(RegistryHandler):
pass
Handler.store = store
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
base_url = f"http://127.0.0.1:{server.server_port}"
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}/graph/providers?capability_type=runtime-secrets",
timeout=5,
) as response:
providers_payload = json.loads(response.read())
assert providers_payload[0]["provider_id"] == "railiance-platform.openbao.runtime-secrets"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)

View File

@@ -4,11 +4,12 @@ type: workplan
title: "Railiance Ecosystem Registry Service"
domain: railiance
repo: railiance-fabric
status: proposed
status: active
owner: codex
topic_slug: railiance
planning_priority: high
planning_order: 2
state_hub_workstream_id: "eab084f2-b71e-45c7-ae9c-8528b69f8dec"
created: "2026-05-17"
updated: "2026-05-17"
---
@@ -80,8 +81,9 @@ Out of scope:
```task
id: RAIL-FAB-WP-0002-T01
status: proposed
status: done
priority: high
state_hub_task_id: "e3c219cf-1b81-4209-9f11-a79a78e1bb52"
```
Define the API surface, storage tables, validation semantics, and snapshot
@@ -94,8 +96,9 @@ identifies request/response shapes and storage ownership.
```task
id: RAIL-FAB-WP-0002-T02
status: proposed
status: done
priority: high
state_hub_task_id: "ef7363a1-afae-4ac2-a977-6d162b3714e6"
```
Create a lightweight HTTP service that reuses the existing Python loader,
@@ -107,8 +110,9 @@ Done when the service can start locally and expose a health endpoint.
```task
id: RAIL-FAB-WP-0002-T03
status: proposed
status: done
priority: high
state_hub_task_id: "59323dfe-4702-4b8a-bd60-86d2caea4618"
```
Add endpoints and storage for repository slug, repo URL, default branch,
@@ -120,8 +124,9 @@ Done when repos can be registered, listed, and fetched by slug.
```task
id: RAIL-FAB-WP-0002-T04
status: proposed
status: done
priority: high
state_hub_task_id: "807fcb38-839f-43fd-9f45-ad5cd1f70d8f"
```
Add atomic ingestion for `FabricGraphExport` payloads keyed by repo and commit.
@@ -133,8 +138,9 @@ errors, and the latest accepted snapshot is queryable.
```task
id: RAIL-FAB-WP-0002-T05
status: proposed
status: done
priority: high
state_hub_task_id: "f3dd0aba-b83c-4066-b0eb-efb07284a7ac"
```
Expose providers, consumers, unresolved dependencies, dependency paths, and
@@ -146,8 +152,9 @@ Done when HTTP responses match the local CLI answers for the same graph.
```task
id: RAIL-FAB-WP-0002-T06
status: proposed
status: todo
priority: medium
state_hub_task_id: "95e4e60b-9d32-407e-83d4-c2a532047775"
```
Support artifact metadata for CycloneDX SBOMs, OpenAPI contracts, AsyncAPI
@@ -160,8 +167,9 @@ in graph node details.
```task
id: RAIL-FAB-WP-0002-T07
status: proposed
status: done
priority: high
state_hub_task_id: "5ef7ccea-fd79-498b-99ce-b6bacb00d46d"
```
Expose State Hub export data from the registry's latest accepted snapshots.
@@ -173,8 +181,9 @@ Done when State Hub can fetch the same graph shape documented in
```task
id: RAIL-FAB-WP-0002-T08
status: proposed
status: todo
priority: medium
state_hub_task_id: "285215e6-6018-44be-abea-56eb79c5d349"
```
Document and, if small enough, prototype Backstage and xRegistry projections.