generated from coulomb/repo-seed
Start ecosystem registry service
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user