diff --git a/railiance_fabric/graph_explorer.py b/railiance_fabric/graph_explorer.py index c5e6e31..2fc04d9 100644 --- a/railiance_fabric/graph_explorer.py +++ b/railiance_fabric/graph_explorer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ipaddress from datetime import datetime, timezone from re import sub from typing import Any @@ -10,6 +11,10 @@ DISPLAY_STATES = ("show", "blur", "hide", "highlight", "remove") LAYER_ORDER = ( "repository", "server", + "runtime_service", + "application", + "network", + "domain", "deployment", "service", "capability", @@ -22,6 +27,10 @@ LAYER_ORDER = ( _KIND_LAYER = { "Repository": "repository", "Server": "server", + "RuntimeService": "runtime_service", + "ApplicationEndpoint": "application", + "NetworkPort": "network", + "DomainName": "domain", "Deployment": "deployment", "ServiceDeclaration": "service", "CapabilityDeclaration": "capability", @@ -34,6 +43,10 @@ _KIND_LAYER = { _LAYER_COLORS = { "repository": "#475569", "server": "#334155", + "runtime_service": "#0369a1", + "application": "#0f766e", + "network": "#64748b", + "domain": "#9333ea", "deployment": "#16a34a", "service": "#0f766e", "capability": "#2563eb", @@ -51,6 +64,13 @@ _EDGE_STRENGTH = { "uses_interface": "medium", "declares": "weak", "deployed_as": "medium", + "exposes_port": "strong", + "listens_on": "strong", + "names_endpoint": "medium", + "opens_port": "strong", + "routes_to_port": "medium", + "routes_to_service": "strong", + "resolves_to": "medium", "runs_on": "strong", "owns_deployment": "weak", } @@ -287,12 +307,13 @@ def fabric_graph_explorer_payload( node_id = str(node.get("id", "")) if not node_id: continue - kind = str(node.get("kind", "")) - if kind == "Repository": + source_kind = str(node.get("kind", "")) + if source_kind == "Repository": continue + attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {} + kind = _presentation_kind(source_kind, attributes) layer = _layer_for_kind(kind) is_unresolved = node_id in unresolved - attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {} review_state = str(attributes.get("discovery_review_state") or "accepted") confidence = attributes.get("discovery_confidence") elements.append( @@ -318,7 +339,7 @@ def fabric_graph_explorer_payload( ), "visualSize": 34 if layer == "binding" else 46 if is_unresolved else 50, "ownership": str(attributes.get("owner") or attributes.get("discovery_origin") or "repo"), - "attributes": attributes, + "attributes": {**attributes, "source_kind": source_kind} if source_kind != kind else attributes, "discovery": { "stableKey": attributes.get("discovery_stable_key", ""), "origin": attributes.get("discovery_origin", ""), @@ -342,6 +363,7 @@ def fabric_graph_explorer_payload( node_layers = _node_data_index(elements, "layer") node_repos = _node_data_index(elements, "repo") + node_kinds = _node_data_index(elements, "kind") edge_index = _append_infrastructure_elements( source_nodes, elements, @@ -353,7 +375,7 @@ def fabric_graph_explorer_payload( for edge in source_edges: source = source_repository_node_ids.get(str(edge.get("from", "")), str(edge.get("from", ""))) target = source_repository_node_ids.get(str(edge.get("to", "")), str(edge.get("to", ""))) - edge_type = str(edge.get("type", "")) + edge_type = _presentation_edge_type(str(edge.get("type", "")), source, target, node_kinds) if not source or not target: continue elements.append(_edge_element(edge_index, source, target, edge_type, node_layers, node_repos)) @@ -422,6 +444,30 @@ def _layer_for_kind(kind: str) -> str: return _KIND_LAYER.get(kind, kind.lower() or "unknown") +def _presentation_kind(kind: str, attributes: dict[str, Any]) -> str: + if kind != "Server": + return kind + host = str(attributes.get("host") or "").strip().lower() + runtime_type = str(attributes.get("runtime_target_type") or attributes.get("server_type") or "") + if runtime_type == "kubernetes-service-dns" or host.endswith(".svc.cluster.local"): + return "RuntimeService" + if runtime_type in {"declared-endpoint", "ingress-host"} and _looks_like_domain(host): + return "ApplicationEndpoint" + return kind + + +def _presentation_edge_type(edge_type: str, source: str, target: str, node_kinds: dict[str, str]) -> str: + if edge_type == "resolves_to": + target_kind = node_kinds.get(target, "") + if target_kind == "ApplicationEndpoint": + return "names_endpoint" + if target_kind == "RuntimeService": + return "routes_to_service" + if edge_type == "opens_port" and node_kinds.get(source, "") in {"ApplicationEndpoint", "RuntimeService"}: + return "listens_on" + return edge_type + + def _edge_strength(edge_type: str) -> str: if edge_type.startswith("binds:"): status = edge_type.split(":", 1)[1] @@ -448,6 +494,26 @@ def _node_data_index(elements: list[dict[str, Any]], field: str) -> dict[str, st return index +def _runtime_node_indexes(source_nodes: list[dict[str, Any]]) -> tuple[dict[str, str], dict[str, str]]: + servers_by_host: dict[str, str] = {} + ports_by_endpoint: dict[str, str] = {} + for node in source_nodes: + node_id = str(node.get("id") or "") + if not node_id: + continue + attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {} + kind = _presentation_kind(str(node.get("kind") or ""), attributes) + host = _normalize_endpoint_host(str(attributes.get("host") or "")) + if kind == "Server" and host: + servers_by_host.setdefault(host, node_id) + if kind == "NetworkPort" and host: + port = _int_value(attributes.get("port")) + protocol = _normalize_protocol(str(attributes.get("protocol") or "tcp")) + if port is not None: + ports_by_endpoint.setdefault(_endpoint_key(host, port, protocol), node_id) + return servers_by_host, ports_by_endpoint + + def _append_infrastructure_elements( source_nodes: list[dict[str, Any]], elements: list[dict[str, Any]], @@ -457,7 +523,19 @@ def _append_infrastructure_elements( ) -> int: edge_index = 0 endpoints_by_service = _endpoints_by_service(source_nodes) - server_ids_by_host: dict[str, str] = {} + server_ids_by_host, port_ids_by_endpoint = _runtime_node_indexes(source_nodes) + generated_edge_keys: set[tuple[str, str, str]] = set() + + def append_edge(source: str, target: str, edge_type: str) -> None: + nonlocal edge_index + if not source or not target: + return + key = (source, edge_type, target) + if key in generated_edge_keys: + return + generated_edge_keys.add(key) + elements.append(_edge_element(edge_index, source, target, edge_type, node_layers, node_repos)) + edge_index += 1 service_nodes = sorted( (node for node in source_nodes if node.get("kind") == "ServiceDeclaration"), @@ -516,31 +594,35 @@ def _append_infrastructure_elements( node_layers[deployment_id] = "deployment" node_repos[deployment_id] = repo - elements.append(_edge_element(edge_index, service_id, deployment_id, "deployed_as", node_layers, node_repos)) - edge_index += 1 + append_edge(service_id, deployment_id, "deployed_as") if repo and repo in repo_slugs: repo_id = f"repo:{repo}" - elements.append(_edge_element(edge_index, repo_id, deployment_id, "owns_deployment", node_layers, node_repos)) - edge_index += 1 + append_edge(repo_id, deployment_id, "owns_deployment") for endpoint in matching_endpoints: - server_id = server_ids_by_host.get(endpoint["host"]) - if server_id is None: - server_id = _server_id(endpoint["host"]) - server_ids_by_host[endpoint["host"]] = server_id + host = endpoint["host"] + port = endpoint["port"] + protocol = endpoint["protocol"] + server_id = server_ids_by_host.get(host) + endpoint_key = _endpoint_key(host, port, protocol) + port_id = port_ids_by_endpoint.get(endpoint_key) + port_was_generated = port_id is None + if server_id is None and _looks_like_machine_address(host): + server_id = _server_id(host) + server_ids_by_host[host] = server_id server_data = { "id": server_id, "stableKey": server_id, "kind": "Server", "layer": "server", - "label": endpoint["host"], - "name": endpoint["host"], + "label": host, + "name": host, "description": f"Server inferred from endpoint {endpoint['url']}.", "repo": "", "domain": str(service.get("domain") or ""), "lifecycle": "active", "environment": environment, - "serverHost": endpoint["host"], + "serverHost": host, "reviewState": "accepted", "freshnessState": "current", "unresolved": False, @@ -548,7 +630,7 @@ def _append_infrastructure_elements( "visualSize": 48, "ownership": "inferred", "attributes": { - "host": endpoint["host"], + "host": host, "source_interface_id": endpoint["interface_id"], "endpoint_url": endpoint["url"], }, @@ -561,8 +643,49 @@ def _append_infrastructure_elements( elements.append({"data": server_data, "classes": "server accepted inferred"}) node_layers[server_id] = "server" node_repos[server_id] = "" - elements.append(_edge_element(edge_index, deployment_id, server_id, "runs_on", node_layers, node_repos)) - edge_index += 1 + if port_id is None: + port_id = _port_id(host, port, protocol) + port_ids_by_endpoint[endpoint_key] = port_id + port_data = { + "id": port_id, + "stableKey": port_id, + "kind": "NetworkPort", + "layer": "network", + "label": f"{host}:{port}/{protocol}", + "name": f"{host}:{port}/{protocol}", + "description": f"Port inferred from endpoint {endpoint['url']}.", + "repo": "", + "domain": str(service.get("domain") or ""), + "lifecycle": "active", + "environment": environment, + "serverHost": host, + "reviewState": "accepted", + "freshnessState": "current", + "unresolved": False, + "confidence": 0.7, + "visualSize": 42, + "ownership": "inferred", + "attributes": { + "host": host, + "port": port, + "protocol": protocol, + "source_interface_id": endpoint["interface_id"], + "endpoint_url": endpoint["url"], + }, + "displayState": "show", + "visibilitySource": "default", + "visibilityReason": "default", + "sourceReferences": [{"label": "Endpoint interface", "ref": endpoint["interface_id"]}], + "deepLinks": {"interface": f"/graph/nodes/{endpoint['interface_id']}"}, + } + elements.append({"data": port_data, "classes": "network accepted inferred"}) + node_layers[port_id] = "network" + node_repos[port_id] = "" + if server_id: + append_edge(deployment_id, server_id, "runs_on") + if port_was_generated: + append_edge(server_id, port_id, "opens_port") + append_edge(deployment_id, port_id, "exposes_port") return edge_index @@ -574,13 +697,16 @@ def _endpoints_by_service(source_nodes: list[dict[str, Any]]) -> dict[str, list[ attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {} endpoint = attributes.get("endpoint") if isinstance(attributes.get("endpoint"), dict) else {} url = str(endpoint.get("url") or "").strip() - host = _endpoint_host(url) + parsed = _parse_endpoint_url(url) service_id = str(attributes.get("service_id") or "") - if not service_id or not host: + if not service_id or not parsed: continue + host, port, protocol = parsed endpoints.setdefault(service_id, []).append( { "host": host, + "port": port, + "protocol": protocol, "url": url, "interface_id": str(node.get("id") or ""), "environments": _environments(attributes), @@ -607,11 +733,70 @@ def _environment_matches(deployment_environment: str, endpoint_environments: lis def _endpoint_host(url: str) -> str: + parsed = _parse_endpoint_url(url) + return parsed[0] if parsed else "" + + +def _parse_endpoint_url(url: str) -> tuple[str, int, str] | None: if not url: - return "" + return None parsed = urlparse(url) - host = parsed.netloc or parsed.path.split("/", 1)[0] - return host.strip().lower() + host = _normalize_endpoint_host(parsed.hostname or parsed.netloc or parsed.path.split("/", 1)[0]) + try: + port = parsed.port + except ValueError: + port = None + scheme = str(parsed.scheme or "").lower() + port = port or {"http": 80, "https": 443, "postgres": 5432}.get(scheme) + if not host or port is None: + return None + return host, port, "tcp" + + +def _normalize_endpoint_host(host: str) -> str: + value = str(host or "").strip().lower().strip("[]") + if value in {"0.0.0.0", "::"}: + return "localhost" + return value + + +def _endpoint_key(host: str, port: int, protocol: str) -> str: + return f"{_normalize_endpoint_host(host)}:{port}/{_normalize_protocol(protocol)}" + + +def _normalize_protocol(protocol: str) -> str: + return str(protocol or "tcp").strip().lower() or "tcp" + + +def _int_value(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + text = str(value or "").strip() + if text.isdecimal(): + return int(text) + return None + + +def _looks_like_domain(host: str) -> bool: + value = _normalize_endpoint_host(host) + if not value or value == "localhost": + return False + if all(part.isdecimal() for part in value.split(".") if part): + return False + return "." in value + + +def _looks_like_machine_address(host: str) -> bool: + value = _normalize_endpoint_host(host) + if value in {"localhost", "127.0.0.1"}: + return True + try: + ipaddress.ip_address(value) + except ValueError: + return False + return True def _server_id(host: str) -> str: @@ -619,6 +804,11 @@ def _server_id(host: str) -> str: return f"server:{key or 'unknown'}" +def _port_id(host: str, port: int, protocol: str) -> str: + key = sub(r"[^A-Za-z0-9._:+-]+", "-", _endpoint_key(host, port, protocol)).strip("-") + return f"port:{key or 'unknown'}" + + def _edge_element( edge_index: int, source: str, @@ -744,6 +934,17 @@ def _node_description(kind: str, attributes: object) -> str: ) if kind == "BindingAssertion": return str(attributes.get("status", "")) + if kind == "RuntimeService": + return str(attributes.get("runtime_target_type") or attributes.get("service_type") or "runtime service") + if kind == "ApplicationEndpoint": + return str(attributes.get("endpoint_url") or attributes.get("domain") or "application endpoint") + if kind == "NetworkPort": + host = str(attributes.get("host") or "") + port = str(attributes.get("port") or "") + protocol = str(attributes.get("protocol") or "") + return " ".join(part for part in (host, port, protocol) if part) + if kind == "DomainName": + return str(attributes.get("domain") or "") return "" diff --git a/railiance_fabric/reconciliation.py b/railiance_fabric/reconciliation.py index b4b0e4f..27a4a22 100644 --- a/railiance_fabric/reconciliation.py +++ b/railiance_fabric/reconciliation.py @@ -38,9 +38,12 @@ PATH_SCOPED_NODE_KINDS = { } EVIDENCE_AGGREGATE_EDGE_TYPES = { "exposes_port", + "listens_on", + "names_endpoint", "opens_port", "resolves_to", "routes_to_port", + "routes_to_service", } diff --git a/railiance_fabric/scanner.py b/railiance_fabric/scanner.py index 76941e5..10d8c7c 100644 --- a/railiance_fabric/scanner.py +++ b/railiance_fabric/scanner.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import ipaddress import re import subprocess import tomllib @@ -1118,10 +1119,11 @@ def _add_runtime_endpoint( if not host or port_number is None: return "" - server_key = discovery_stable_key(context.repo_slug, "Server", host) + target_kind = _runtime_target_kind(host, server_type) + target_key = discovery_stable_key(context.repo_slug, target_kind, host) context.accumulator.add_node( - stable_key=server_key, - kind="Server", + stable_key=target_key, + kind=target_kind, label=host, replacement_scope=scope, provenance=provenance, @@ -1129,7 +1131,7 @@ def _add_runtime_endpoint( aliases=[host], attributes={ "host": host, - "server_type": server_type, + "runtime_target_type": server_type, **(attributes or {}), }, confidence=confidence, @@ -1154,8 +1156,8 @@ def _add_runtime_endpoint( confidence=confidence, ) context.accumulator.add_edge( - edge_type="opens_port", - source_key=server_key, + edge_type="opens_port" if target_kind == "Server" else "listens_on", + source_key=target_key, target_key=port_key, replacement_scope=scope, provenance=provenance, @@ -1174,9 +1176,31 @@ def _add_runtime_endpoint( ) route_domain = _normalize_domain(domain) if route_domain: - _add_domain_route(context, scope, provenance, anchor, route_domain, port_key, host, confidence=confidence) + _add_domain_route( + context, + scope, + provenance, + anchor, + route_domain, + port_key, + host, + runtime_target_key=target_key, + runtime_target_kind=target_kind, + confidence=confidence, + ) elif _looks_like_domain(host): - _add_domain_route(context, scope, provenance, anchor, host, port_key, host, confidence=confidence) + _add_domain_route( + context, + scope, + provenance, + anchor, + host, + port_key, + host, + runtime_target_key=target_key, + runtime_target_kind=target_kind, + confidence=confidence, + ) return port_key @@ -1225,13 +1249,14 @@ def _add_domain_route( port_key: str, server_host: str, *, + runtime_target_key: str = "", + runtime_target_kind: str = "", confidence: float, ) -> None: domain_value = _normalize_domain(domain) if not domain_value: return domain_key = discovery_stable_key(context.repo_slug, "DomainName", domain_value) - server_key = discovery_stable_key(context.repo_slug, "Server", _normalize_host(server_host)) context.accumulator.add_node( stable_key=domain_key, kind="DomainName", @@ -1253,7 +1278,23 @@ def _add_domain_route( source_anchor=anchor, confidence=confidence, ) - if server_host: + if runtime_target_key: + edge_type = { + "ApplicationEndpoint": "names_endpoint", + "RuntimeService": "routes_to_service", + "Server": "resolves_to", + }.get(runtime_target_kind, "routes_to") + context.accumulator.add_edge( + edge_type=edge_type, + source_key=domain_key, + target_key=runtime_target_key, + replacement_scope=scope, + provenance=provenance, + source_anchor=anchor, + confidence=confidence, + ) + elif server_host: + server_key = discovery_stable_key(context.repo_slug, "Server", _normalize_host(server_host)) context.accumulator.add_edge( edge_type="resolves_to", source_key=domain_key, @@ -1509,6 +1550,28 @@ def _looks_like_domain(host: str) -> bool: return "." in value +def _runtime_target_kind(host: str, runtime_target_type: str) -> str: + value = _normalize_host(host) + if _looks_like_machine_address(value): + return "Server" + if runtime_target_type == "kubernetes-service-dns" or value.endswith(".svc.cluster.local"): + return "RuntimeService" + if runtime_target_type == "ingress-host" or _looks_like_domain(value): + return "ApplicationEndpoint" + return "RuntimeService" + + +def _looks_like_machine_address(host: str) -> bool: + value = _normalize_host(host) + if value in {"localhost", "127.0.0.1"}: + return True + try: + ipaddress.ip_address(value) + except ValueError: + return False + return True + + def _int_value(value: object) -> int | None: if isinstance(value, bool): return None diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index f1ad411..129a609 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -42,7 +42,14 @@ def test_graph_explorer_manifest_and_payload_validate() -> None: assert manifest["profile_persistence"] == "local" assert manifest["shareable_state"]["profile_id"] is True assert set(manifest["filter"]["actions"]) >= {"show", "hide", "blur", "highlight", "remove"} - assert {layer["id"] for layer in manifest["layers"]} >= {"server", "deployment"} + assert {layer["id"] for layer in manifest["layers"]} >= { + "server", + "runtime_service", + "application", + "network", + "domain", + "deployment", + } filter_labels = {field["id"]: field["label"] for field in manifest["filter"]["fields"]} assert filter_labels["layer"] == "Node Type" nodes = [element for element in payload["elements"] if "source" not in element["data"]] @@ -50,9 +57,11 @@ def test_graph_explorer_manifest_and_payload_validate() -> None: registered_only = next( element for element in nodes if element["data"]["id"] == "repo:registered-only" ) - deployment = next(element for element in nodes if element["data"]["kind"] == "Deployment") - server = next(element for element in nodes if element["data"]["kind"] == "Server") + nodes_by_id = {element["data"]["id"]: element for element in nodes} runs_on = next(edge for edge in edges if edge["data"]["edgeType"] == "runs_on") + deployment = nodes_by_id[runs_on["data"]["source"]] + server = nodes_by_id[runs_on["data"]["target"]] + network_port = next(element for element in nodes if element["data"]["kind"] == "NetworkPort") same_repo_edge = next(edge for edge in edges if edge["data"].get("sameRepo") is True) cross_repo_edge = next(edge for edge in edges if edge["data"].get("layoutAffinity") == "cross-repo") @@ -60,6 +69,21 @@ def test_graph_explorer_manifest_and_payload_validate() -> None: assert registered_only["data"]["unresolved"] is True assert deployment["data"]["layer"] == "deployment" assert server["data"]["layer"] == "server" + assert ":" not in server["data"]["label"] + assert network_port["data"]["layer"] == "network" + assert network_port["data"]["label"].endswith("/tcp") + assert ( + len( + [ + edge + for edge in edges + if edge["data"]["edgeType"] == "runs_on" + and edge["data"]["source"] == deployment["data"]["id"] + and edge["data"]["target"] == server["data"]["id"] + ] + ) + == 1 + ) assert runs_on["data"]["layoutIdealLength"] < cross_repo_edge["data"]["layoutIdealLength"] assert runs_on["data"]["layoutElasticity"] > cross_repo_edge["data"]["layoutElasticity"] assert same_repo_edge["data"]["layoutIdealLength"] < cross_repo_edge["data"]["layoutIdealLength"] @@ -123,6 +147,90 @@ def test_graph_explorer_collapses_discovered_repository_nodes() -> None: assert declares_package["data"]["target"] == "discovery:fixture-repo:library:fixture-service" +def test_graph_explorer_presents_legacy_server_nodes_as_runtime_entities() -> None: + graph = { + "apiVersion": "railiance.fabric/v1alpha1", + "kind": "FabricGraphExport", + "nodes": [ + { + "id": "fixture.server.gitea.example.test", + "kind": "Server", + "name": "gitea.example.test", + "repo": "fixture-repo", + "domain": "testing", + "lifecycle": "active", + "attributes": {"host": "gitea.example.test", "server_type": "ingress-host"}, + }, + { + "id": "fixture.server.gitea.default.svc.cluster.local", + "kind": "Server", + "name": "gitea.default.svc.cluster.local", + "repo": "fixture-repo", + "domain": "testing", + "lifecycle": "active", + "attributes": {"host": "gitea.default.svc.cluster.local", "server_type": "kubernetes-service-dns"}, + }, + { + "id": "fixture.domain.gitea.example.test", + "kind": "DomainName", + "name": "gitea.example.test", + "repo": "fixture-repo", + "domain": "testing", + "lifecycle": "active", + "attributes": {"domain": "gitea.example.test"}, + }, + { + "id": "fixture.port.gitea.default.svc.cluster.local-3000-tcp", + "kind": "NetworkPort", + "name": "gitea.default.svc.cluster.local:3000/tcp", + "repo": "fixture-repo", + "domain": "testing", + "lifecycle": "active", + "attributes": {"host": "gitea.default.svc.cluster.local", "port": 3000, "protocol": "tcp"}, + }, + ], + "edges": [ + { + "from": "fixture.domain.gitea.example.test", + "to": "fixture.server.gitea.example.test", + "type": "resolves_to", + }, + { + "from": "fixture.server.gitea.default.svc.cluster.local", + "to": "fixture.port.gitea.default.svc.cluster.local-3000-tcp", + "type": "opens_port", + }, + ], + } + + payload = fabric_graph_explorer_payload(graph, [{"slug": "fixture-repo", "name": "Fixture Repo"}], {"fixture-repo"}) + nodes_by_id = { + element["data"]["id"]: element["data"] + for element in payload["elements"] + if "source" not in element["data"] + } + + assert nodes_by_id["fixture.server.gitea.example.test"]["kind"] == "ApplicationEndpoint" + assert nodes_by_id["fixture.server.gitea.example.test"]["layer"] == "application" + assert nodes_by_id["fixture.server.gitea.default.svc.cluster.local"]["kind"] == "RuntimeService" + assert nodes_by_id["fixture.server.gitea.default.svc.cluster.local"]["layer"] == "runtime_service" + edge_types = { + (element["data"]["source"], element["data"]["target"]): element["data"]["edgeType"] + for element in payload["elements"] + if "source" in element["data"] + } + assert edge_types[("fixture.domain.gitea.example.test", "fixture.server.gitea.example.test")] == "names_endpoint" + assert ( + edge_types[ + ( + "fixture.server.gitea.default.svc.cluster.local", + "fixture.port.gitea.default.svc.cluster.local-3000-tcp", + ) + ] + == "listens_on" + ) + + def test_cli_exports_graph_explorer_payload(capsys) -> None: assert cli_main(["export", "--format", "graph-explorer"]) == 0 payload = json.loads(capsys.readouterr().out) diff --git a/tests/test_scanner.py b/tests/test_scanner.py index c8eb759..3b63eb3 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -40,9 +40,15 @@ def test_scan_repo_emits_schema_valid_deterministic_snapshot(tmp_path: Path) -> assert nodes_by_label[("ScoreWorkload", "fixture-api")]["attributes"]["container_count"] == 1 assert nodes_by_label[("Lockfile", "package-lock.json")]["attributes"]["path"] == "package-lock.json" assert nodes_by_label[("ServiceConfig", "application.yaml")]["attributes"]["format"] == "yaml" - assert nodes_by_label[("Server", "127.0.0.1")]["attributes"]["server_type"] == "compose-host" - assert nodes_by_label[("Server", "fixture-api.testing.svc.cluster.local")]["attributes"]["server_type"] == "kubernetes-service-dns" - assert nodes_by_label[("Server", "declared.fixture.test")]["attributes"]["server_type"] == "declared-endpoint" + assert nodes_by_label[("Server", "127.0.0.1")]["attributes"]["runtime_target_type"] == "compose-host" + assert ( + nodes_by_label[("RuntimeService", "fixture-api.testing.svc.cluster.local")]["attributes"]["runtime_target_type"] + == "kubernetes-service-dns" + ) + assert ( + nodes_by_label[("ApplicationEndpoint", "declared.fixture.test")]["attributes"]["runtime_target_type"] + == "declared-endpoint" + ) assert nodes_by_label[("NetworkPort", "127.0.0.1:8080/tcp")]["attributes"]["target_port"] == 8080 assert nodes_by_label[("NetworkPort", "fixture-api.testing.svc.cluster.local:8080/tcp")]["attributes"]["service_port"] == 8080 assert nodes_by_label[("NetworkPort", "declared.fixture.test:9443/tcp")]["attributes"]["scheme"] == "https" @@ -63,8 +69,11 @@ def test_scan_repo_emits_schema_valid_deterministic_snapshot(tmp_path: Path) -> "provides", "exposes", "opens_port", + "listens_on", + "names_endpoint", "exposes_port", "routes_to_port", + "routes_to_service", "resolves_to", } assert {attribute["name"] for attribute in candidates["attributes"]} >= { diff --git a/workplans/RAIL-FAB-WP-0015-runtime-entity-taxonomy.md b/workplans/RAIL-FAB-WP-0015-runtime-entity-taxonomy.md new file mode 100644 index 0000000..a4dc895 --- /dev/null +++ b/workplans/RAIL-FAB-WP-0015-runtime-entity-taxonomy.md @@ -0,0 +1,122 @@ +--- +id: RAIL-FAB-WP-0015 +type: workplan +title: "Runtime Entity Taxonomy Refinement" +domain: railiance +repo: railiance-fabric +status: finished +owner: codex +topic_slug: railiance +planning_priority: high +planning_order: 15 +created: "2026-05-21" +updated: "2026-05-21" +state_hub_workstream_id: "958559cc-c640-40d6-afe3-467ee0e9e973" +--- + +# RAIL-FAB-WP-0015 - Runtime Entity Taxonomy Refinement + +## Goal + +Refine runtime topology discovery and graph rendering so servers, services, +applications, ports, and domains are distinct entities instead of variants of +the old catch-all `Server` node. + +## Background + +The first runtime topology pass intentionally used conservative `Server`, +`NetworkPort`, and `DomainName` nodes. After projecting all candidates into the +graph, this proved too broad: + +- `127.0.0.1:8765` is a port endpoint, not a server. +- `gitea.coulomb.social` is an application-facing endpoint, not a machine. +- `pink-account.coulomb.social` should be connected through the privacyIDEA + service/application topology instead of floating as a repo-declared server. + +## Target Model + +- `Server` is reserved for concrete machine or host addresses such as IPs and + localhost loopback addresses. +- `RuntimeService` represents a running service target such as a Kubernetes + Service DNS name or a declared service endpoint. +- `ApplicationEndpoint` represents user-facing application endpoints exposed + through domains, ingress hosts, or declared HTTP URLs. +- `NetworkPort` remains the separate port/protocol binding node. +- `DomainName` remains the DNS name and routes to the port/service/application + it describes. + +## Tasks + +### T01 - Update Runtime Scanner Taxonomy + +```task +id: RAIL-FAB-WP-0015-T01 +status: done +priority: high +state_hub_task_id: "c19f55e2-dd68-4cb9-8c21-fd9c61a3ab25" +``` + +Emit refined runtime candidates from Compose, Kubernetes, and Fabric endpoint +evidence without using `Server` for domain names or service DNS targets. + +### T02 - Update Graph Explorer Runtime Projection + +```task +id: RAIL-FAB-WP-0015-T02 +status: done +priority: high +state_hub_task_id: "b5caa75d-04bf-4eb3-9b61-f28eb85fe9d4" +``` + +Render legacy accepted `Server` candidates as the refined presentation kind +where their attributes make the intent clear, and stop inferring `host:port` +values as server nodes. + +### T03 - Preserve Port And Service Relationships + +```task +id: RAIL-FAB-WP-0015-T03 +status: done +priority: medium +state_hub_task_id: "06b19102-d5f9-4707-af86-7fb248126374" +``` + +Keep existing port/domain relationships while adding explicit relationships +from runtime services and application endpoints to their ports. Deduplicate +inferred deployment `runs_on` edges. + +### T04 - Verify Fixtures And Live Graph Shape + +```task +id: RAIL-FAB-WP-0015-T04 +status: done +priority: high +state_hub_task_id: "d14e85b4-4323-4ce6-9e25-bc92293dc351" +``` + +Update scanner and graph explorer tests, then verify the live graph no longer +classifies application domains or `host:port` endpoints as servers. + +Verification result: + +- `python3 -m pytest tests/test_graph_explorer.py tests/test_scanner.py -q` + passed with 8 tests. +- `python3 -m pytest` passed with 35 tests. +- Restarted the local registry on port 8765. +- Live graph explorer export now shows: + - `127.0.0.1:8765` as a `NetworkPort`. + - `gitea.coulomb.social` and `pink-account.coulomb.social` as + `ApplicationEndpoint` nodes where legacy discovery previously rendered + them as `Server`. + - `privacyidea.mfa.svc.cluster.local` as a `RuntimeService`. + - 0 `Server` nodes whose label contains `host:port`. + - 1 `runs_on` edge from `deployment:railiance-fabric.registry:dev`. + +## Close Criteria + +- Scanner tests distinguish `Server`, `RuntimeService`, `ApplicationEndpoint`, + `NetworkPort`, and `DomainName`. +- Graph explorer tests validate endpoint parsing and duplicate `runs_on` + removal. +- The local graph export shows application domains as applications, service DNS + names as runtime services, and `127.0.0.1:8765` as a port.