generated from coulomb/repo-seed
Start ecosystem registry service
This commit is contained in:
10
README.md
10
README.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*"]
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
482
railiance_fabric/registry.py
Normal file
482
railiance_fabric/registry.py
Normal 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
149
railiance_fabric/server.py
Normal 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())
|
||||
@@ -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
82
tests/test_registry.py
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user