Refine runtime entity taxonomy

This commit is contained in:
2026-05-21 21:28:34 +02:00
parent 072fa8f7a7
commit 01bc4f3efe
6 changed files with 547 additions and 41 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import ipaddress
from datetime import datetime, timezone from datetime import datetime, timezone
from re import sub from re import sub
from typing import Any from typing import Any
@@ -10,6 +11,10 @@ DISPLAY_STATES = ("show", "blur", "hide", "highlight", "remove")
LAYER_ORDER = ( LAYER_ORDER = (
"repository", "repository",
"server", "server",
"runtime_service",
"application",
"network",
"domain",
"deployment", "deployment",
"service", "service",
"capability", "capability",
@@ -22,6 +27,10 @@ LAYER_ORDER = (
_KIND_LAYER = { _KIND_LAYER = {
"Repository": "repository", "Repository": "repository",
"Server": "server", "Server": "server",
"RuntimeService": "runtime_service",
"ApplicationEndpoint": "application",
"NetworkPort": "network",
"DomainName": "domain",
"Deployment": "deployment", "Deployment": "deployment",
"ServiceDeclaration": "service", "ServiceDeclaration": "service",
"CapabilityDeclaration": "capability", "CapabilityDeclaration": "capability",
@@ -34,6 +43,10 @@ _KIND_LAYER = {
_LAYER_COLORS = { _LAYER_COLORS = {
"repository": "#475569", "repository": "#475569",
"server": "#334155", "server": "#334155",
"runtime_service": "#0369a1",
"application": "#0f766e",
"network": "#64748b",
"domain": "#9333ea",
"deployment": "#16a34a", "deployment": "#16a34a",
"service": "#0f766e", "service": "#0f766e",
"capability": "#2563eb", "capability": "#2563eb",
@@ -51,6 +64,13 @@ _EDGE_STRENGTH = {
"uses_interface": "medium", "uses_interface": "medium",
"declares": "weak", "declares": "weak",
"deployed_as": "medium", "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", "runs_on": "strong",
"owns_deployment": "weak", "owns_deployment": "weak",
} }
@@ -287,12 +307,13 @@ def fabric_graph_explorer_payload(
node_id = str(node.get("id", "")) node_id = str(node.get("id", ""))
if not node_id: if not node_id:
continue continue
kind = str(node.get("kind", "")) source_kind = str(node.get("kind", ""))
if kind == "Repository": if source_kind == "Repository":
continue continue
attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {}
kind = _presentation_kind(source_kind, attributes)
layer = _layer_for_kind(kind) layer = _layer_for_kind(kind)
is_unresolved = node_id in unresolved 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") review_state = str(attributes.get("discovery_review_state") or "accepted")
confidence = attributes.get("discovery_confidence") confidence = attributes.get("discovery_confidence")
elements.append( elements.append(
@@ -318,7 +339,7 @@ def fabric_graph_explorer_payload(
), ),
"visualSize": 34 if layer == "binding" else 46 if is_unresolved else 50, "visualSize": 34 if layer == "binding" else 46 if is_unresolved else 50,
"ownership": str(attributes.get("owner") or attributes.get("discovery_origin") or "repo"), "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": { "discovery": {
"stableKey": attributes.get("discovery_stable_key", ""), "stableKey": attributes.get("discovery_stable_key", ""),
"origin": attributes.get("discovery_origin", ""), "origin": attributes.get("discovery_origin", ""),
@@ -342,6 +363,7 @@ def fabric_graph_explorer_payload(
node_layers = _node_data_index(elements, "layer") node_layers = _node_data_index(elements, "layer")
node_repos = _node_data_index(elements, "repo") node_repos = _node_data_index(elements, "repo")
node_kinds = _node_data_index(elements, "kind")
edge_index = _append_infrastructure_elements( edge_index = _append_infrastructure_elements(
source_nodes, source_nodes,
elements, elements,
@@ -353,7 +375,7 @@ def fabric_graph_explorer_payload(
for edge in source_edges: for edge in source_edges:
source = source_repository_node_ids.get(str(edge.get("from", "")), str(edge.get("from", ""))) 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", ""))) 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: if not source or not target:
continue continue
elements.append(_edge_element(edge_index, source, target, edge_type, node_layers, node_repos)) 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") 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: def _edge_strength(edge_type: str) -> str:
if edge_type.startswith("binds:"): if edge_type.startswith("binds:"):
status = edge_type.split(":", 1)[1] 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 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( def _append_infrastructure_elements(
source_nodes: list[dict[str, Any]], source_nodes: list[dict[str, Any]],
elements: list[dict[str, Any]], elements: list[dict[str, Any]],
@@ -457,7 +523,19 @@ def _append_infrastructure_elements(
) -> int: ) -> int:
edge_index = 0 edge_index = 0
endpoints_by_service = _endpoints_by_service(source_nodes) 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( service_nodes = sorted(
(node for node in source_nodes if node.get("kind") == "ServiceDeclaration"), (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_layers[deployment_id] = "deployment"
node_repos[deployment_id] = repo node_repos[deployment_id] = repo
elements.append(_edge_element(edge_index, service_id, deployment_id, "deployed_as", node_layers, node_repos)) append_edge(service_id, deployment_id, "deployed_as")
edge_index += 1
if repo and repo in repo_slugs: if repo and repo in repo_slugs:
repo_id = f"repo:{repo}" repo_id = f"repo:{repo}"
elements.append(_edge_element(edge_index, repo_id, deployment_id, "owns_deployment", node_layers, node_repos)) append_edge(repo_id, deployment_id, "owns_deployment")
edge_index += 1
for endpoint in matching_endpoints: for endpoint in matching_endpoints:
server_id = server_ids_by_host.get(endpoint["host"]) host = endpoint["host"]
if server_id is None: port = endpoint["port"]
server_id = _server_id(endpoint["host"]) protocol = endpoint["protocol"]
server_ids_by_host[endpoint["host"]] = server_id 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 = { server_data = {
"id": server_id, "id": server_id,
"stableKey": server_id, "stableKey": server_id,
"kind": "Server", "kind": "Server",
"layer": "server", "layer": "server",
"label": endpoint["host"], "label": host,
"name": endpoint["host"], "name": host,
"description": f"Server inferred from endpoint {endpoint['url']}.", "description": f"Server inferred from endpoint {endpoint['url']}.",
"repo": "", "repo": "",
"domain": str(service.get("domain") or ""), "domain": str(service.get("domain") or ""),
"lifecycle": "active", "lifecycle": "active",
"environment": environment, "environment": environment,
"serverHost": endpoint["host"], "serverHost": host,
"reviewState": "accepted", "reviewState": "accepted",
"freshnessState": "current", "freshnessState": "current",
"unresolved": False, "unresolved": False,
@@ -548,7 +630,7 @@ def _append_infrastructure_elements(
"visualSize": 48, "visualSize": 48,
"ownership": "inferred", "ownership": "inferred",
"attributes": { "attributes": {
"host": endpoint["host"], "host": host,
"source_interface_id": endpoint["interface_id"], "source_interface_id": endpoint["interface_id"],
"endpoint_url": endpoint["url"], "endpoint_url": endpoint["url"],
}, },
@@ -561,8 +643,49 @@ def _append_infrastructure_elements(
elements.append({"data": server_data, "classes": "server accepted inferred"}) elements.append({"data": server_data, "classes": "server accepted inferred"})
node_layers[server_id] = "server" node_layers[server_id] = "server"
node_repos[server_id] = "" node_repos[server_id] = ""
elements.append(_edge_element(edge_index, deployment_id, server_id, "runs_on", node_layers, node_repos)) if port_id is None:
edge_index += 1 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 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 {} attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {}
endpoint = attributes.get("endpoint") if isinstance(attributes.get("endpoint"), dict) else {} endpoint = attributes.get("endpoint") if isinstance(attributes.get("endpoint"), dict) else {}
url = str(endpoint.get("url") or "").strip() url = str(endpoint.get("url") or "").strip()
host = _endpoint_host(url) parsed = _parse_endpoint_url(url)
service_id = str(attributes.get("service_id") or "") service_id = str(attributes.get("service_id") or "")
if not service_id or not host: if not service_id or not parsed:
continue continue
host, port, protocol = parsed
endpoints.setdefault(service_id, []).append( endpoints.setdefault(service_id, []).append(
{ {
"host": host, "host": host,
"port": port,
"protocol": protocol,
"url": url, "url": url,
"interface_id": str(node.get("id") or ""), "interface_id": str(node.get("id") or ""),
"environments": _environments(attributes), "environments": _environments(attributes),
@@ -607,11 +733,70 @@ def _environment_matches(deployment_environment: str, endpoint_environments: lis
def _endpoint_host(url: str) -> str: 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: if not url:
return "" return None
parsed = urlparse(url) parsed = urlparse(url)
host = parsed.netloc or parsed.path.split("/", 1)[0] host = _normalize_endpoint_host(parsed.hostname or parsed.netloc or parsed.path.split("/", 1)[0])
return host.strip().lower() 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: def _server_id(host: str) -> str:
@@ -619,6 +804,11 @@ def _server_id(host: str) -> str:
return f"server:{key or 'unknown'}" 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( def _edge_element(
edge_index: int, edge_index: int,
source: str, source: str,
@@ -744,6 +934,17 @@ def _node_description(kind: str, attributes: object) -> str:
) )
if kind == "BindingAssertion": if kind == "BindingAssertion":
return str(attributes.get("status", "")) 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 "" return ""

View File

@@ -38,9 +38,12 @@ PATH_SCOPED_NODE_KINDS = {
} }
EVIDENCE_AGGREGATE_EDGE_TYPES = { EVIDENCE_AGGREGATE_EDGE_TYPES = {
"exposes_port", "exposes_port",
"listens_on",
"names_endpoint",
"opens_port", "opens_port",
"resolves_to", "resolves_to",
"routes_to_port", "routes_to_port",
"routes_to_service",
} }

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import ipaddress
import re import re
import subprocess import subprocess
import tomllib import tomllib
@@ -1118,10 +1119,11 @@ def _add_runtime_endpoint(
if not host or port_number is None: if not host or port_number is None:
return "" 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( context.accumulator.add_node(
stable_key=server_key, stable_key=target_key,
kind="Server", kind=target_kind,
label=host, label=host,
replacement_scope=scope, replacement_scope=scope,
provenance=provenance, provenance=provenance,
@@ -1129,7 +1131,7 @@ def _add_runtime_endpoint(
aliases=[host], aliases=[host],
attributes={ attributes={
"host": host, "host": host,
"server_type": server_type, "runtime_target_type": server_type,
**(attributes or {}), **(attributes or {}),
}, },
confidence=confidence, confidence=confidence,
@@ -1154,8 +1156,8 @@ def _add_runtime_endpoint(
confidence=confidence, confidence=confidence,
) )
context.accumulator.add_edge( context.accumulator.add_edge(
edge_type="opens_port", edge_type="opens_port" if target_kind == "Server" else "listens_on",
source_key=server_key, source_key=target_key,
target_key=port_key, target_key=port_key,
replacement_scope=scope, replacement_scope=scope,
provenance=provenance, provenance=provenance,
@@ -1174,9 +1176,31 @@ def _add_runtime_endpoint(
) )
route_domain = _normalize_domain(domain) route_domain = _normalize_domain(domain)
if route_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): 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 return port_key
@@ -1225,13 +1249,14 @@ def _add_domain_route(
port_key: str, port_key: str,
server_host: str, server_host: str,
*, *,
runtime_target_key: str = "",
runtime_target_kind: str = "",
confidence: float, confidence: float,
) -> None: ) -> None:
domain_value = _normalize_domain(domain) domain_value = _normalize_domain(domain)
if not domain_value: if not domain_value:
return return
domain_key = discovery_stable_key(context.repo_slug, "DomainName", domain_value) 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( context.accumulator.add_node(
stable_key=domain_key, stable_key=domain_key,
kind="DomainName", kind="DomainName",
@@ -1253,7 +1278,23 @@ def _add_domain_route(
source_anchor=anchor, source_anchor=anchor,
confidence=confidence, 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( context.accumulator.add_edge(
edge_type="resolves_to", edge_type="resolves_to",
source_key=domain_key, source_key=domain_key,
@@ -1509,6 +1550,28 @@ def _looks_like_domain(host: str) -> bool:
return "." in value 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: def _int_value(value: object) -> int | None:
if isinstance(value, bool): if isinstance(value, bool):
return None return None

View File

@@ -42,7 +42,14 @@ def test_graph_explorer_manifest_and_payload_validate() -> None:
assert manifest["profile_persistence"] == "local" assert manifest["profile_persistence"] == "local"
assert manifest["shareable_state"]["profile_id"] is True assert manifest["shareable_state"]["profile_id"] is True
assert set(manifest["filter"]["actions"]) >= {"show", "hide", "blur", "highlight", "remove"} 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"]} filter_labels = {field["id"]: field["label"] for field in manifest["filter"]["fields"]}
assert filter_labels["layer"] == "Node Type" assert filter_labels["layer"] == "Node Type"
nodes = [element for element in payload["elements"] if "source" not in element["data"]] 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( registered_only = next(
element for element in nodes if element["data"]["id"] == "repo:registered-only" element for element in nodes if element["data"]["id"] == "repo:registered-only"
) )
deployment = next(element for element in nodes if element["data"]["kind"] == "Deployment") nodes_by_id = {element["data"]["id"]: element for element in nodes}
server = next(element for element in nodes if element["data"]["kind"] == "Server")
runs_on = next(edge for edge in edges if edge["data"]["edgeType"] == "runs_on") 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) 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") 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 registered_only["data"]["unresolved"] is True
assert deployment["data"]["layer"] == "deployment" assert deployment["data"]["layer"] == "deployment"
assert server["data"]["layer"] == "server" 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"]["layoutIdealLength"] < cross_repo_edge["data"]["layoutIdealLength"]
assert runs_on["data"]["layoutElasticity"] > cross_repo_edge["data"]["layoutElasticity"] assert runs_on["data"]["layoutElasticity"] > cross_repo_edge["data"]["layoutElasticity"]
assert same_repo_edge["data"]["layoutIdealLength"] < cross_repo_edge["data"]["layoutIdealLength"] 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" 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: def test_cli_exports_graph_explorer_payload(capsys) -> None:
assert cli_main(["export", "--format", "graph-explorer"]) == 0 assert cli_main(["export", "--format", "graph-explorer"]) == 0
payload = json.loads(capsys.readouterr().out) payload = json.loads(capsys.readouterr().out)

View File

@@ -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[("ScoreWorkload", "fixture-api")]["attributes"]["container_count"] == 1
assert nodes_by_label[("Lockfile", "package-lock.json")]["attributes"]["path"] == "package-lock.json" 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[("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", "127.0.0.1")]["attributes"]["runtime_target_type"] == "compose-host"
assert nodes_by_label[("Server", "fixture-api.testing.svc.cluster.local")]["attributes"]["server_type"] == "kubernetes-service-dns" assert (
assert nodes_by_label[("Server", "declared.fixture.test")]["attributes"]["server_type"] == "declared-endpoint" 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", "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", "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" 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", "provides",
"exposes", "exposes",
"opens_port", "opens_port",
"listens_on",
"names_endpoint",
"exposes_port", "exposes_port",
"routes_to_port", "routes_to_port",
"routes_to_service",
"resolves_to", "resolves_to",
} }
assert {attribute["name"] for attribute in candidates["attributes"]} >= { assert {attribute["name"] for attribute in candidates["attributes"]} >= {

View File

@@ -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.