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 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.
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. 7. CycloneDX SBOM attachment for library/package inventory.
8. CloudEvents-style registry events once mutation endpoints exist. 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 ## Open Design Questions
- Should the registry pull repos itself, or should repos/agents push validated - Should the registry pull repos itself, or should repos/agents push validated

View File

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

View File

@@ -194,6 +194,7 @@ class FabricGraph:
"repo": declaration.metadata.get("repo", ""), "repo": declaration.metadata.get("repo", ""),
"domain": declaration.metadata.get("domain", ""), "domain": declaration.metadata.get("domain", ""),
"lifecycle": declaration.spec.get("lifecycle", ""), "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: def _escape_mermaid(value: str) -> str:
return value.replace('"', '\\"') 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 type: string
lifecycle: lifecycle:
type: string type: string
attributes:
type: object
additionalProperties: true
edges: edges:
type: array type: array
items: 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" title: "Railiance Ecosystem Registry Service"
domain: railiance domain: railiance
repo: railiance-fabric repo: railiance-fabric
status: proposed status: active
owner: codex owner: codex
topic_slug: railiance topic_slug: railiance
planning_priority: high planning_priority: high
planning_order: 2 planning_order: 2
state_hub_workstream_id: "eab084f2-b71e-45c7-ae9c-8528b69f8dec"
created: "2026-05-17" created: "2026-05-17"
updated: "2026-05-17" updated: "2026-05-17"
--- ---
@@ -80,8 +81,9 @@ Out of scope:
```task ```task
id: RAIL-FAB-WP-0002-T01 id: RAIL-FAB-WP-0002-T01
status: proposed status: done
priority: high priority: high
state_hub_task_id: "e3c219cf-1b81-4209-9f11-a79a78e1bb52"
``` ```
Define the API surface, storage tables, validation semantics, and snapshot Define the API surface, storage tables, validation semantics, and snapshot
@@ -94,8 +96,9 @@ identifies request/response shapes and storage ownership.
```task ```task
id: RAIL-FAB-WP-0002-T02 id: RAIL-FAB-WP-0002-T02
status: proposed status: done
priority: high priority: high
state_hub_task_id: "ef7363a1-afae-4ac2-a977-6d162b3714e6"
``` ```
Create a lightweight HTTP service that reuses the existing Python loader, 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 ```task
id: RAIL-FAB-WP-0002-T03 id: RAIL-FAB-WP-0002-T03
status: proposed status: done
priority: high priority: high
state_hub_task_id: "59323dfe-4702-4b8a-bd60-86d2caea4618"
``` ```
Add endpoints and storage for repository slug, repo URL, default branch, 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 ```task
id: RAIL-FAB-WP-0002-T04 id: RAIL-FAB-WP-0002-T04
status: proposed status: done
priority: high priority: high
state_hub_task_id: "807fcb38-839f-43fd-9f45-ad5cd1f70d8f"
``` ```
Add atomic ingestion for `FabricGraphExport` payloads keyed by repo and commit. Add atomic ingestion for `FabricGraphExport` payloads keyed by repo and commit.
@@ -133,8 +138,9 @@ errors, and the latest accepted snapshot is queryable.
```task ```task
id: RAIL-FAB-WP-0002-T05 id: RAIL-FAB-WP-0002-T05
status: proposed status: done
priority: high priority: high
state_hub_task_id: "f3dd0aba-b83c-4066-b0eb-efb07284a7ac"
``` ```
Expose providers, consumers, unresolved dependencies, dependency paths, and 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 ```task
id: RAIL-FAB-WP-0002-T06 id: RAIL-FAB-WP-0002-T06
status: proposed status: todo
priority: medium priority: medium
state_hub_task_id: "95e4e60b-9d32-407e-83d4-c2a532047775"
``` ```
Support artifact metadata for CycloneDX SBOMs, OpenAPI contracts, AsyncAPI Support artifact metadata for CycloneDX SBOMs, OpenAPI contracts, AsyncAPI
@@ -160,8 +167,9 @@ in graph node details.
```task ```task
id: RAIL-FAB-WP-0002-T07 id: RAIL-FAB-WP-0002-T07
status: proposed status: done
priority: high priority: high
state_hub_task_id: "5ef7ccea-fd79-498b-99ce-b6bacb00d46d"
``` ```
Expose State Hub export data from the registry's latest accepted snapshots. 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 ```task
id: RAIL-FAB-WP-0002-T08 id: RAIL-FAB-WP-0002-T08
status: proposed status: todo
priority: medium priority: medium
state_hub_task_id: "285215e6-6018-44be-abea-56eb79c5d349"
``` ```
Document and, if small enough, prototype Backstage and xRegistry projections. Document and, if small enough, prototype Backstage and xRegistry projections.