diff --git a/README.md b/README.md index dfd8556..ccea921 100644 --- a/README.md +++ b/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. diff --git a/docs/ecosystem-registry-service.md b/docs/ecosystem-registry-service.md index ec8e3e1..25e1dc1 100644 --- a/docs/ecosystem-registry-service.md +++ b/docs/ecosystem-registry-service.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 38d51be..fb9e70a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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*"] diff --git a/railiance_fabric/graph.py b/railiance_fabric/graph.py index 12a21c7..77d12d4 100644 --- a/railiance_fabric/graph.py +++ b/railiance_fabric/graph.py @@ -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 {} diff --git a/railiance_fabric/registry.py b/railiance_fabric/registry.py new file mode 100644 index 0000000..2cbce9e --- /dev/null +++ b/railiance_fabric/registry.py @@ -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 "" + 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", ""), + } diff --git a/railiance_fabric/server.py b/railiance_fabric/server.py new file mode 100644 index 0000000..b08fbae --- /dev/null +++ b/railiance_fabric/server.py @@ -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()) diff --git a/schemas/state-hub-export.schema.yaml b/schemas/state-hub-export.schema.yaml index 5753b93..df6d440 100644 --- a/schemas/state-hub-export.schema.yaml +++ b/schemas/state-hub-export.schema.yaml @@ -53,6 +53,9 @@ properties: type: string lifecycle: type: string + attributes: + type: object + additionalProperties: true edges: type: array items: diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..0024b45 --- /dev/null +++ b/tests/test_registry.py @@ -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) diff --git a/workplans/RAIL-FAB-WP-0002-ecosystem-registry-service.md b/workplans/RAIL-FAB-WP-0002-ecosystem-registry-service.md index 0d0a8e6..a36e235 100644 --- a/workplans/RAIL-FAB-WP-0002-ecosystem-registry-service.md +++ b/workplans/RAIL-FAB-WP-0002-ecosystem-registry-service.md @@ -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.