generated from coulomb/repo-seed
Establish Railiance Fabric graph model
This commit is contained in:
5
railiance_fabric/__init__.py
Normal file
5
railiance_fabric/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Railiance Fabric graph tooling."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
183
railiance_fabric/cli.py
Normal file
183
railiance_fabric/cli.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .graph import FabricGraph, build_graph
|
||||
from .validation import validate_roots
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="railiance-fabric",
|
||||
description="Load and validate Railiance Fabric declarations.",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
validate = sub.add_parser(
|
||||
"validate",
|
||||
help="Validate one or more repo roots or declaration files.",
|
||||
)
|
||||
validate.add_argument(
|
||||
"paths",
|
||||
nargs="+",
|
||||
type=Path,
|
||||
help="Repo root, fabric directory, or declaration YAML file.",
|
||||
)
|
||||
validate.add_argument(
|
||||
"--warnings-as-errors",
|
||||
action="store_true",
|
||||
help="Exit non-zero when warnings are present.",
|
||||
)
|
||||
|
||||
providers = sub.add_parser("providers", help="List providers for a capability type or id.")
|
||||
providers.add_argument("capability", help="Capability type or capability id.")
|
||||
providers.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
||||
|
||||
consumers = sub.add_parser("consumers", help="List consumers of a capability or interface.")
|
||||
consumers.add_argument("target", help="Capability/interface type or declaration id.")
|
||||
consumers.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
||||
|
||||
dependency_path = sub.add_parser("dependency-path", help="Show dependency path for a service.")
|
||||
dependency_path.add_argument("service_id", help="Service declaration id.")
|
||||
dependency_path.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
||||
|
||||
unresolved = sub.add_parser("unresolved", help="Show missing or unresolved dependencies.")
|
||||
unresolved.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
||||
|
||||
blast = sub.add_parser("blast-radius", help="Show consumers affected by an interface change.")
|
||||
blast.add_argument("interface", help="Interface type or interface declaration id.")
|
||||
blast.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
||||
|
||||
export = sub.add_parser("export", help="Export graph as JSON or Mermaid.")
|
||||
export.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
||||
export.add_argument("--format", choices=["json", "mermaid"], default="json")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "validate":
|
||||
report = validate_roots(args.paths)
|
||||
for diagnostic in report.diagnostics:
|
||||
print(diagnostic.format())
|
||||
print(report.summary())
|
||||
if report.errors:
|
||||
return 1
|
||||
if args.warnings_as_errors and report.warnings:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == "providers":
|
||||
graph = _load_graph_or_exit(args.paths)
|
||||
_print_providers(graph, args.capability)
|
||||
return 0
|
||||
|
||||
if args.command == "consumers":
|
||||
graph = _load_graph_or_exit(args.paths)
|
||||
_print_consumers(graph, args.target)
|
||||
return 0
|
||||
|
||||
if args.command == "dependency-path":
|
||||
graph = _load_graph_or_exit(args.paths)
|
||||
print("\n".join(graph.dependency_path_lines(args.service_id)))
|
||||
return 0
|
||||
|
||||
if args.command == "unresolved":
|
||||
graph = _load_graph_or_exit(args.paths)
|
||||
_print_unresolved(graph)
|
||||
return 0
|
||||
|
||||
if args.command == "blast-radius":
|
||||
graph = _load_graph_or_exit(args.paths)
|
||||
_print_consumers(graph, args.interface, matches=graph.blast_radius(args.interface))
|
||||
return 0
|
||||
|
||||
if args.command == "export":
|
||||
graph = _load_graph_or_exit(args.paths)
|
||||
print(graph.to_mermaid() if args.format == "mermaid" else graph.to_json())
|
||||
return 0
|
||||
|
||||
parser.error(f"unknown command {args.command!r}")
|
||||
return 2
|
||||
|
||||
def _load_graph_or_exit(paths: list[Path]) -> FabricGraph:
|
||||
graph = build_graph(paths)
|
||||
if graph.load_errors:
|
||||
for path, message in graph.load_errors:
|
||||
print(f"ERROR {path}: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
return graph
|
||||
|
||||
|
||||
def _print_providers(graph: FabricGraph, capability: str) -> None:
|
||||
providers = graph.providers(capability)
|
||||
if not providers:
|
||||
print(f"no providers found for {capability}")
|
||||
return
|
||||
print("provider_id\tservice_id\tlifecycle\tenvironments\tinterfaces")
|
||||
for provider in providers:
|
||||
spec = provider.spec
|
||||
print(
|
||||
"\t".join(
|
||||
[
|
||||
provider.id,
|
||||
str(spec.get("service_id", "")),
|
||||
str(spec.get("lifecycle", "")),
|
||||
",".join(spec.get("environments", [])),
|
||||
",".join(spec.get("interface_ids", [])),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _print_consumers(
|
||||
graph: FabricGraph,
|
||||
target: str,
|
||||
matches: object | None = None,
|
||||
) -> None:
|
||||
consumer_matches = graph.consumers(target) if matches is None else list(matches)
|
||||
if not consumer_matches:
|
||||
print(f"no consumers found for {target}")
|
||||
return
|
||||
print("consumer_service_id\tdependency_id\trequires\tprovider_capability_id\tprovider_interface_id\tstatus")
|
||||
for match in consumer_matches:
|
||||
print(
|
||||
"\t".join(
|
||||
[
|
||||
match.consumer_service_id,
|
||||
match.dependency_id,
|
||||
match.required_capability_type,
|
||||
match.provider_capability_id,
|
||||
match.provider_interface_id,
|
||||
match.status,
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _print_unresolved(graph: FabricGraph) -> None:
|
||||
unresolved = graph.unresolved_dependencies()
|
||||
if not unresolved:
|
||||
print("no unresolved dependencies")
|
||||
return
|
||||
print("dependency_id\tconsumer_service_id\trequires")
|
||||
for dependency in unresolved:
|
||||
spec = dependency.spec
|
||||
requires = spec.get("requires", {})
|
||||
print(
|
||||
"\t".join(
|
||||
[
|
||||
dependency.id,
|
||||
str(spec.get("consumer_service_id", "")),
|
||||
str(requires.get("capability_id") or requires.get("capability_type", "")),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
264
railiance_fabric/graph.py
Normal file
264
railiance_fabric/graph.py
Normal file
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .loader import load_declarations
|
||||
from .model import Declaration
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConsumerMatch:
|
||||
consumer_service_id: str
|
||||
dependency_id: str
|
||||
required_capability_type: str
|
||||
provider_capability_id: str = ""
|
||||
provider_interface_id: str = ""
|
||||
status: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FabricGraph:
|
||||
declarations: list[Declaration]
|
||||
load_errors: list[tuple[Path, str]] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.by_id: dict[str, Declaration] = {}
|
||||
self.by_kind: dict[str, list[Declaration]] = defaultdict(list)
|
||||
for declaration in self.declarations:
|
||||
if declaration.id and declaration.id not in self.by_id:
|
||||
self.by_id[declaration.id] = declaration
|
||||
self.by_kind[declaration.kind].append(declaration)
|
||||
|
||||
self.services = {d.id: d for d in self.by_kind["ServiceDeclaration"]}
|
||||
self.capabilities = {d.id: d for d in self.by_kind["CapabilityDeclaration"]}
|
||||
self.interfaces = {d.id: d for d in self.by_kind["InterfaceDeclaration"]}
|
||||
self.dependencies = {d.id: d for d in self.by_kind["DependencyDeclaration"]}
|
||||
self.bindings = {d.id: d for d in self.by_kind["BindingAssertion"]}
|
||||
self.bindings_by_dependency: dict[str, list[Declaration]] = defaultdict(list)
|
||||
for binding in self.bindings.values():
|
||||
dependency_id = str(binding.spec.get("dependency_id", ""))
|
||||
if dependency_id:
|
||||
self.bindings_by_dependency[dependency_id].append(binding)
|
||||
|
||||
def providers(self, capability: str) -> list[Declaration]:
|
||||
if capability in self.capabilities:
|
||||
return [self.capabilities[capability]]
|
||||
return [
|
||||
declaration
|
||||
for declaration in self.capabilities.values()
|
||||
if declaration.spec.get("capability_type") == capability
|
||||
]
|
||||
|
||||
def consumers(self, target: str) -> list[ConsumerMatch]:
|
||||
matches: list[ConsumerMatch] = []
|
||||
target_interface_type = self.interfaces.get(target, Declaration(Path(), {})).spec.get("interface_type")
|
||||
|
||||
for dependency in self.dependencies.values():
|
||||
spec = dependency.spec
|
||||
requires = spec.get("requires", {})
|
||||
dependency_matches = (
|
||||
target == dependency.id
|
||||
or target == requires.get("capability_id")
|
||||
or target == requires.get("capability_type")
|
||||
or target == spec.get("interface", {}).get("type")
|
||||
or (target_interface_type and target_interface_type == spec.get("interface", {}).get("type"))
|
||||
)
|
||||
|
||||
dependency_bindings = self.bindings_by_dependency.get(dependency.id, [])
|
||||
binding_matches = [
|
||||
binding
|
||||
for binding in dependency_bindings
|
||||
if target in {
|
||||
binding.spec.get("provider_capability_id"),
|
||||
binding.spec.get("provider_interface_id"),
|
||||
}
|
||||
]
|
||||
|
||||
if dependency_matches and not dependency_bindings:
|
||||
matches.append(
|
||||
ConsumerMatch(
|
||||
consumer_service_id=str(spec.get("consumer_service_id", "")),
|
||||
dependency_id=dependency.id,
|
||||
required_capability_type=str(requires.get("capability_type", "")),
|
||||
)
|
||||
)
|
||||
for binding in dependency_bindings:
|
||||
if dependency_matches or binding in binding_matches:
|
||||
matches.append(
|
||||
ConsumerMatch(
|
||||
consumer_service_id=str(spec.get("consumer_service_id", "")),
|
||||
dependency_id=dependency.id,
|
||||
required_capability_type=str(requires.get("capability_type", "")),
|
||||
provider_capability_id=str(binding.spec.get("provider_capability_id", "")),
|
||||
provider_interface_id=str(binding.spec.get("provider_interface_id", "")),
|
||||
status=str(binding.spec.get("status", "")),
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(matches, key=lambda item: (item.consumer_service_id, item.dependency_id))
|
||||
|
||||
def unresolved_dependencies(self) -> list[Declaration]:
|
||||
unresolved: list[Declaration] = []
|
||||
for dependency in self.dependencies.values():
|
||||
providers = self.matching_providers(dependency)
|
||||
bindings = self.bindings_by_dependency.get(dependency.id, [])
|
||||
has_missing_binding = any(binding.spec.get("status") in {"missing", "disputed"} for binding in bindings)
|
||||
if not providers or has_missing_binding:
|
||||
unresolved.append(dependency)
|
||||
return sorted(unresolved, key=lambda item: item.id)
|
||||
|
||||
def matching_providers(self, dependency: Declaration) -> list[Declaration]:
|
||||
requires = dependency.spec.get("requires", {})
|
||||
capability_id = requires.get("capability_id")
|
||||
if capability_id:
|
||||
provider = self.capabilities.get(str(capability_id))
|
||||
return [provider] if provider is not None else []
|
||||
capability_type = requires.get("capability_type")
|
||||
return [
|
||||
provider
|
||||
for provider in self.capabilities.values()
|
||||
if provider.spec.get("capability_type") == capability_type
|
||||
]
|
||||
|
||||
def dependency_path_lines(self, service_id: str) -> list[str]:
|
||||
if service_id not in self.services:
|
||||
return [f"unknown service: {service_id}"]
|
||||
|
||||
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}")
|
||||
deps = [
|
||||
dep
|
||||
for dep in self.dependencies.values()
|
||||
if dep.spec.get("consumer_service_id") == current
|
||||
]
|
||||
if not deps:
|
||||
lines.append(f"{prefix} no declared dependencies")
|
||||
return
|
||||
for dep in sorted(deps, key=lambda item: item.id):
|
||||
required = dep.spec.get("requires", {}).get("capability_type", "")
|
||||
lines.append(f"{prefix} requires {required}: {dep.id}")
|
||||
bindings = self.bindings_by_dependency.get(dep.id, [])
|
||||
if not bindings:
|
||||
providers = self.matching_providers(dep)
|
||||
if providers:
|
||||
for provider in providers:
|
||||
lines.append(f"{prefix} candidate {provider.id}")
|
||||
else:
|
||||
lines.append(f"{prefix} unresolved")
|
||||
continue
|
||||
for binding in sorted(bindings, key=lambda item: item.id):
|
||||
provider_id = str(binding.spec.get("provider_capability_id", ""))
|
||||
provider = self.capabilities.get(provider_id)
|
||||
provider_service = provider.spec.get("service_id") if provider else ""
|
||||
status = binding.spec.get("status", "")
|
||||
lines.append(f"{prefix} {status} -> {provider_id}")
|
||||
if provider_service and provider_service != current:
|
||||
walk(str(provider_service), indent + 3, stack + [current])
|
||||
|
||||
walk(service_id, 0, [])
|
||||
return lines
|
||||
|
||||
def blast_radius(self, interface: str) -> list[ConsumerMatch]:
|
||||
if interface in self.interfaces:
|
||||
return [
|
||||
match
|
||||
for match in self.consumers(interface)
|
||||
if match.provider_interface_id == interface
|
||||
]
|
||||
return [
|
||||
match
|
||||
for match in self.consumers(interface)
|
||||
if self.dependencies[match.dependency_id].spec.get("interface", {}).get("type") == interface
|
||||
]
|
||||
|
||||
def to_export(self) -> dict[str, Any]:
|
||||
nodes: list[dict[str, Any]] = []
|
||||
edges: list[dict[str, str]] = []
|
||||
|
||||
for declaration in sorted(self.declarations, key=lambda item: (item.kind, item.id)):
|
||||
nodes.append(
|
||||
{
|
||||
"id": declaration.id,
|
||||
"kind": declaration.kind,
|
||||
"name": declaration.metadata.get("name", declaration.id),
|
||||
"repo": declaration.metadata.get("repo", ""),
|
||||
"domain": declaration.metadata.get("domain", ""),
|
||||
"lifecycle": declaration.spec.get("lifecycle", ""),
|
||||
}
|
||||
)
|
||||
|
||||
for service in self.services.values():
|
||||
for capability_id in service.spec.get("provides_capabilities", []):
|
||||
edges.append({"from": service.id, "to": capability_id, "type": "provides"})
|
||||
for interface_id in service.spec.get("exposes_interfaces", []):
|
||||
edges.append({"from": service.id, "to": interface_id, "type": "exposes"})
|
||||
|
||||
for capability in self.capabilities.values():
|
||||
for interface_id in capability.spec.get("interface_ids", []):
|
||||
edges.append({"from": capability.id, "to": interface_id, "type": "available_via"})
|
||||
|
||||
for dependency in self.dependencies.values():
|
||||
consumer = str(dependency.spec.get("consumer_service_id", ""))
|
||||
if consumer:
|
||||
edges.append({"from": consumer, "to": dependency.id, "type": "consumes"})
|
||||
for binding in self.bindings_by_dependency.get(dependency.id, []):
|
||||
edges.append(
|
||||
{
|
||||
"from": dependency.id,
|
||||
"to": str(binding.spec.get("provider_capability_id", "")),
|
||||
"type": f"binds:{binding.spec.get('status', '')}",
|
||||
}
|
||||
)
|
||||
interface_id = str(binding.spec.get("provider_interface_id", ""))
|
||||
if interface_id:
|
||||
edges.append({"from": dependency.id, "to": interface_id, "type": "uses_interface"})
|
||||
|
||||
return {
|
||||
"apiVersion": "railiance.fabric/v1alpha1",
|
||||
"kind": "FabricGraphExport",
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
}
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_export(), indent=2, sort_keys=True)
|
||||
|
||||
def to_mermaid(self) -> str:
|
||||
export = self.to_export()
|
||||
lines = ["graph TD"]
|
||||
for node in export["nodes"]:
|
||||
node_id = _mermaid_id(node["id"])
|
||||
label = f"{node['name']}\\n{node['kind']}"
|
||||
lines.append(f' {node_id}["{_escape_mermaid(label)}"]')
|
||||
for edge in export["edges"]:
|
||||
source = _mermaid_id(edge["from"])
|
||||
target = _mermaid_id(edge["to"])
|
||||
label = _escape_mermaid(edge["type"])
|
||||
lines.append(f" {source} -- {label} --> {target}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_graph(paths: list[Path]) -> FabricGraph:
|
||||
declarations, load_errors = load_declarations(paths)
|
||||
return FabricGraph(declarations=declarations, load_errors=load_errors)
|
||||
|
||||
|
||||
def _mermaid_id(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9_]", "_", value)
|
||||
if cleaned and cleaned[0].isdigit():
|
||||
cleaned = "_" + cleaned
|
||||
return cleaned or "unknown"
|
||||
|
||||
|
||||
def _escape_mermaid(value: str) -> str:
|
||||
return value.replace('"', '\\"')
|
||||
56
railiance_fabric/loader.py
Normal file
56
railiance_fabric/loader.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .model import Declaration
|
||||
|
||||
DECLARATION_DIRS = ("services", "capabilities", "interfaces", "dependencies", "bindings")
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def declaration_files(path: Path) -> list[Path]:
|
||||
path = path.resolve()
|
||||
if path.is_file():
|
||||
return [path]
|
||||
|
||||
fabric = path if path.name == "fabric" else path / "fabric"
|
||||
files: list[Path] = []
|
||||
for directory in DECLARATION_DIRS:
|
||||
root = fabric / directory
|
||||
if root.is_dir():
|
||||
files.extend(sorted(root.glob("*.yaml")))
|
||||
files.extend(sorted(root.glob("*.yml")))
|
||||
return files
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> Any:
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_declarations(paths: list[Path]) -> tuple[list[Declaration], list[tuple[Path, str]]]:
|
||||
declarations: list[Declaration] = []
|
||||
errors: list[tuple[Path, str]] = []
|
||||
seen_files: set[Path] = set()
|
||||
|
||||
for raw in paths:
|
||||
for path in declaration_files(raw):
|
||||
if path in seen_files:
|
||||
continue
|
||||
seen_files.add(path)
|
||||
try:
|
||||
data = load_yaml(path)
|
||||
except Exception as exc:
|
||||
errors.append((path, str(exc)))
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
errors.append((path, "declaration must be a YAML mapping"))
|
||||
continue
|
||||
declarations.append(Declaration(path=path, data=data))
|
||||
|
||||
return declarations, errors
|
||||
65
railiance_fabric/model.py
Normal file
65
railiance_fabric/model.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Declaration:
|
||||
path: Path
|
||||
data: dict[str, Any]
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
return str(self.data.get("kind", ""))
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return str(self.data.get("metadata", {}).get("id", ""))
|
||||
|
||||
@property
|
||||
def spec(self) -> dict[str, Any]:
|
||||
spec = self.data.get("spec", {})
|
||||
return spec if isinstance(spec, dict) else {}
|
||||
|
||||
@property
|
||||
def metadata(self) -> dict[str, Any]:
|
||||
meta = self.data.get("metadata", {})
|
||||
return meta if isinstance(meta, dict) else {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Diagnostic:
|
||||
severity: str
|
||||
code: str
|
||||
message: str
|
||||
path: Path | None = None
|
||||
|
||||
def format(self) -> str:
|
||||
prefix = f"{self.severity} {self.code}"
|
||||
if self.path is not None:
|
||||
return f"{prefix} {self.path}: {self.message}"
|
||||
return f"{prefix}: {self.message}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationReport:
|
||||
diagnostics: list[Diagnostic] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def errors(self) -> list[Diagnostic]:
|
||||
return [d for d in self.diagnostics if d.severity == "ERROR"]
|
||||
|
||||
@property
|
||||
def warnings(self) -> list[Diagnostic]:
|
||||
return [d for d in self.diagnostics if d.severity == "WARN"]
|
||||
|
||||
def add(self, severity: str, code: str, message: str, path: Path | None = None) -> None:
|
||||
self.diagnostics.append(Diagnostic(severity, code, message, path))
|
||||
|
||||
def summary(self) -> str:
|
||||
return (
|
||||
f"Validation complete: {len(self.errors)} error(s), "
|
||||
f"{len(self.warnings)} warning(s)"
|
||||
)
|
||||
333
railiance_fabric/validation.py
Normal file
333
railiance_fabric/validation.py
Normal file
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import jsonschema
|
||||
|
||||
from .loader import load_declarations, load_yaml, repo_root
|
||||
from .model import Declaration, ValidationReport
|
||||
|
||||
SCHEMA_BY_KIND = {
|
||||
"ServiceDeclaration": "service.schema.yaml",
|
||||
"CapabilityDeclaration": "capability.schema.yaml",
|
||||
"InterfaceDeclaration": "interface.schema.yaml",
|
||||
"DependencyDeclaration": "dependency.schema.yaml",
|
||||
"BindingAssertion": "binding.schema.yaml",
|
||||
}
|
||||
|
||||
|
||||
def validate_roots(paths: list[Path]) -> ValidationReport:
|
||||
root = repo_root()
|
||||
report = ValidationReport()
|
||||
declarations, load_errors = load_declarations(paths)
|
||||
for path, message in load_errors:
|
||||
report.add("ERROR", "load.yaml", message, path)
|
||||
|
||||
_validate_schema(root, declarations, report)
|
||||
_validate_graph(root, declarations, report)
|
||||
return report
|
||||
|
||||
|
||||
def _validate_schema(root: Path, declarations: list[Declaration], report: ValidationReport) -> None:
|
||||
schemas_dir = root / "schemas"
|
||||
store = {
|
||||
path.resolve().as_uri(): load_yaml(path)
|
||||
for path in sorted(schemas_dir.glob("*.schema.yaml"))
|
||||
}
|
||||
|
||||
for declaration in declarations:
|
||||
schema_name = SCHEMA_BY_KIND.get(declaration.kind)
|
||||
if schema_name is None:
|
||||
report.add(
|
||||
"ERROR",
|
||||
"schema.kind",
|
||||
f"unknown declaration kind {declaration.kind!r}",
|
||||
declaration.path,
|
||||
)
|
||||
continue
|
||||
|
||||
schema_path = schemas_dir / schema_name
|
||||
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)
|
||||
for error in sorted(validator.iter_errors(declaration.data), key=lambda e: list(e.path)):
|
||||
location = ".".join(str(part) for part in error.path) or "<root>"
|
||||
report.add(
|
||||
"ERROR",
|
||||
"schema.invalid",
|
||||
f"{location}: {error.message}",
|
||||
declaration.path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_graph(root: Path, declarations: list[Declaration], report: ValidationReport) -> None:
|
||||
by_id: dict[str, Declaration] = {}
|
||||
by_kind: dict[str, list[Declaration]] = defaultdict(list)
|
||||
|
||||
for declaration in declarations:
|
||||
if not declaration.id:
|
||||
continue
|
||||
if declaration.id in by_id:
|
||||
report.add(
|
||||
"ERROR",
|
||||
"graph.duplicate_id",
|
||||
f"duplicate declaration id {declaration.id!r}",
|
||||
declaration.path,
|
||||
)
|
||||
else:
|
||||
by_id[declaration.id] = declaration
|
||||
by_kind[declaration.kind].append(declaration)
|
||||
|
||||
cap_types, iface_types, expected_ifaces = _load_type_catalog(root, report)
|
||||
services = {d.id: d for d in by_kind["ServiceDeclaration"]}
|
||||
capabilities = {d.id: d for d in by_kind["CapabilityDeclaration"]}
|
||||
interfaces = {d.id: d for d in by_kind["InterfaceDeclaration"]}
|
||||
dependencies = {d.id: d for d in by_kind["DependencyDeclaration"]}
|
||||
|
||||
for declaration in by_kind["CapabilityDeclaration"]:
|
||||
spec = declaration.spec
|
||||
capability_type = spec.get("capability_type")
|
||||
if capability_type not in cap_types:
|
||||
report.add("ERROR", "catalog.unknown_capability_type", f"unknown capability type {capability_type!r}", declaration.path)
|
||||
_require_ref(report, declaration, "service_id", services, spec.get("service_id"))
|
||||
for interface_id in spec.get("interface_ids", []):
|
||||
_require_ref(report, declaration, "interface_ids", interfaces, interface_id)
|
||||
|
||||
for declaration in by_kind["InterfaceDeclaration"]:
|
||||
spec = declaration.spec
|
||||
interface_type = spec.get("interface_type")
|
||||
if interface_type not in iface_types:
|
||||
report.add("ERROR", "catalog.unknown_interface_type", f"unknown interface type {interface_type!r}", declaration.path)
|
||||
_require_ref(report, declaration, "service_id", services, spec.get("service_id"))
|
||||
for capability_id in spec.get("capability_ids", []):
|
||||
_require_ref(report, declaration, "capability_ids", capabilities, capability_id)
|
||||
|
||||
for declaration in by_kind["ServiceDeclaration"]:
|
||||
spec = declaration.spec
|
||||
for capability_id in spec.get("provides_capabilities", []):
|
||||
_require_ref(report, declaration, "provides_capabilities", capabilities, capability_id)
|
||||
for interface_id in spec.get("exposes_interfaces", []):
|
||||
_require_ref(report, declaration, "exposes_interfaces", interfaces, interface_id)
|
||||
|
||||
for declaration in by_kind["DependencyDeclaration"]:
|
||||
_validate_dependency(
|
||||
declaration,
|
||||
report,
|
||||
services,
|
||||
capabilities,
|
||||
interfaces,
|
||||
cap_types,
|
||||
iface_types,
|
||||
expected_ifaces,
|
||||
)
|
||||
|
||||
for declaration in by_kind["BindingAssertion"]:
|
||||
_validate_binding(declaration, report, dependencies, capabilities, interfaces)
|
||||
|
||||
_detect_cycles(by_kind["DependencyDeclaration"], by_kind["BindingAssertion"], services, capabilities, report)
|
||||
|
||||
|
||||
def _load_type_catalog(root: Path, report: ValidationReport) -> tuple[set[str], set[str], dict[str, set[str]]]:
|
||||
cap_types: set[str] = set()
|
||||
iface_types: set[str] = set()
|
||||
expected_ifaces: dict[str, set[str]] = {}
|
||||
|
||||
try:
|
||||
cap_catalog = load_yaml(root / "catalog/capability-types.yaml")
|
||||
for item in cap_catalog["spec"]["types"]:
|
||||
cap_types.add(item["id"])
|
||||
expected_ifaces[item["id"]] = set(item.get("expected_interface_types", []))
|
||||
except Exception as exc:
|
||||
report.add("ERROR", "catalog.load", f"cannot load capability catalog: {exc}")
|
||||
|
||||
try:
|
||||
iface_catalog = load_yaml(root / "catalog/interface-types.yaml")
|
||||
for item in iface_catalog["spec"]["types"]:
|
||||
iface_types.add(item["id"])
|
||||
except Exception as exc:
|
||||
report.add("ERROR", "catalog.load", f"cannot load interface catalog: {exc}")
|
||||
|
||||
return cap_types, iface_types, expected_ifaces
|
||||
|
||||
|
||||
def _require_ref(
|
||||
report: ValidationReport,
|
||||
declaration: Declaration,
|
||||
field: str,
|
||||
collection: dict[str, Declaration],
|
||||
value: Any,
|
||||
) -> None:
|
||||
if isinstance(value, str) and value in collection:
|
||||
return
|
||||
report.add("ERROR", "graph.missing_ref", f"{field} references unknown id {value!r}", declaration.path)
|
||||
|
||||
|
||||
def _validate_dependency(
|
||||
declaration: Declaration,
|
||||
report: ValidationReport,
|
||||
services: dict[str, Declaration],
|
||||
capabilities: dict[str, Declaration],
|
||||
interfaces: dict[str, Declaration],
|
||||
cap_types: set[str],
|
||||
iface_types: set[str],
|
||||
expected_ifaces: dict[str, set[str]],
|
||||
) -> None:
|
||||
spec = declaration.spec
|
||||
_require_ref(report, declaration, "consumer_service_id", services, spec.get("consumer_service_id"))
|
||||
|
||||
requires = spec.get("requires", {})
|
||||
capability_type = requires.get("capability_type")
|
||||
capability_id = requires.get("capability_id")
|
||||
if capability_type not in cap_types:
|
||||
report.add("ERROR", "catalog.unknown_capability_type", f"unknown required capability type {capability_type!r}", declaration.path)
|
||||
if capability_id:
|
||||
_require_ref(report, declaration, "requires.capability_id", capabilities, capability_id)
|
||||
|
||||
interface_type = spec.get("interface", {}).get("type")
|
||||
if interface_type:
|
||||
if interface_type not in iface_types:
|
||||
report.add("ERROR", "catalog.unknown_interface_type", f"unknown dependency interface type {interface_type!r}", declaration.path)
|
||||
expected = expected_ifaces.get(str(capability_type), set())
|
||||
if expected and interface_type not in expected:
|
||||
report.add(
|
||||
"WARN",
|
||||
"graph.unexpected_interface_type",
|
||||
f"interface type {interface_type!r} is not expected for capability type {capability_type!r}",
|
||||
declaration.path,
|
||||
)
|
||||
|
||||
providers = _matching_providers(requires, capabilities)
|
||||
if not providers:
|
||||
report.add(
|
||||
"ERROR",
|
||||
"graph.missing_provider",
|
||||
f"no provider capability found for {requires!r}",
|
||||
declaration.path,
|
||||
)
|
||||
|
||||
if _is_active_production(spec) and not declaration.metadata.get("source_links"):
|
||||
report.add(
|
||||
"ERROR",
|
||||
"graph.missing_source_links",
|
||||
"active production dependency requires metadata.source_links",
|
||||
declaration.path,
|
||||
)
|
||||
|
||||
if _is_active_production(spec):
|
||||
viable = [provider for provider in providers if _provider_covers_dependency(provider.spec, spec)]
|
||||
if providers and not viable:
|
||||
report.add(
|
||||
"ERROR",
|
||||
"graph.incompatible_provider",
|
||||
"no matching provider is active in the dependency environment",
|
||||
declaration.path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_binding(
|
||||
declaration: Declaration,
|
||||
report: ValidationReport,
|
||||
dependencies: dict[str, Declaration],
|
||||
capabilities: dict[str, Declaration],
|
||||
interfaces: dict[str, Declaration],
|
||||
) -> None:
|
||||
spec = declaration.spec
|
||||
dependency = dependencies.get(str(spec.get("dependency_id")))
|
||||
provider = capabilities.get(str(spec.get("provider_capability_id")))
|
||||
_require_ref(report, declaration, "dependency_id", dependencies, spec.get("dependency_id"))
|
||||
_require_ref(report, declaration, "provider_capability_id", capabilities, spec.get("provider_capability_id"))
|
||||
if spec.get("provider_interface_id"):
|
||||
_require_ref(report, declaration, "provider_interface_id", interfaces, spec.get("provider_interface_id"))
|
||||
|
||||
if dependency and provider:
|
||||
required_type = dependency.spec.get("requires", {}).get("capability_type")
|
||||
provider_type = provider.spec.get("capability_type")
|
||||
if required_type != provider_type:
|
||||
report.add(
|
||||
"ERROR",
|
||||
"graph.binding_type_mismatch",
|
||||
f"binding provider type {provider_type!r} does not satisfy dependency type {required_type!r}",
|
||||
declaration.path,
|
||||
)
|
||||
|
||||
|
||||
def _matching_providers(requires: dict[str, Any], capabilities: dict[str, Declaration]) -> list[Declaration]:
|
||||
capability_id = requires.get("capability_id")
|
||||
if capability_id:
|
||||
provider = capabilities.get(str(capability_id))
|
||||
return [provider] if provider is not None else []
|
||||
capability_type = requires.get("capability_type")
|
||||
return [
|
||||
declaration
|
||||
for declaration in capabilities.values()
|
||||
if declaration.spec.get("capability_type") == capability_type
|
||||
]
|
||||
|
||||
|
||||
def _is_active_production(spec: dict[str, Any]) -> bool:
|
||||
environments = set(spec.get("environments", []))
|
||||
return spec.get("lifecycle") == "active" and bool(environments & {"prod", "all"})
|
||||
|
||||
|
||||
def _provider_covers_dependency(provider_spec: dict[str, Any], dependency_spec: dict[str, Any]) -> bool:
|
||||
if provider_spec.get("lifecycle") != "active":
|
||||
return False
|
||||
provider_envs = set(provider_spec.get("environments", []))
|
||||
dependency_envs = set(dependency_spec.get("environments", []))
|
||||
if "all" in provider_envs:
|
||||
return True
|
||||
if "all" in dependency_envs:
|
||||
return {"dev", "staging", "prod"}.issubset(provider_envs)
|
||||
return bool(provider_envs & dependency_envs)
|
||||
|
||||
|
||||
def _detect_cycles(
|
||||
dependencies: list[Declaration],
|
||||
bindings: list[Declaration],
|
||||
services: dict[str, Declaration],
|
||||
capabilities: dict[str, Declaration],
|
||||
report: ValidationReport,
|
||||
) -> None:
|
||||
dependency_by_id = {d.id: d for d in dependencies}
|
||||
provider_by_dependency: dict[str, Declaration] = {}
|
||||
for binding in bindings:
|
||||
dep = str(binding.spec.get("dependency_id"))
|
||||
provider_capability_id = str(binding.spec.get("provider_capability_id"))
|
||||
provider = capabilities.get(provider_capability_id)
|
||||
if dep and provider:
|
||||
provider_by_dependency[dep] = provider
|
||||
|
||||
edges: dict[str, set[str]] = defaultdict(set)
|
||||
for dep_id, provider in provider_by_dependency.items():
|
||||
dependency = dependency_by_id.get(dep_id)
|
||||
if dependency is None:
|
||||
continue
|
||||
consumer_service = dependency.spec.get("consumer_service_id")
|
||||
provider_service = provider.spec.get("service_id")
|
||||
if consumer_service in services and provider_service in services and consumer_service != provider_service:
|
||||
edges[str(consumer_service)].add(str(provider_service))
|
||||
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def visit(node: str, stack: list[str]) -> None:
|
||||
if node in visiting:
|
||||
cycle = stack[stack.index(node):] + [node]
|
||||
report.add("WARN", "graph.cycle", "service dependency cycle: " + " -> ".join(cycle))
|
||||
return
|
||||
if node in visited:
|
||||
return
|
||||
visiting.add(node)
|
||||
for target in edges.get(node, set()):
|
||||
visit(target, stack + [target])
|
||||
visiting.remove(node)
|
||||
visited.add(node)
|
||||
|
||||
for node in sorted(edges):
|
||||
visit(node, [node])
|
||||
Reference in New Issue
Block a user