Discover runtime topology facts

This commit is contained in:
2026-05-21 00:25:45 +02:00
parent af4132c182
commit a8e67db9e9
4 changed files with 744 additions and 4 deletions

View File

@@ -16,7 +16,32 @@ ORIGIN_PRECEDENCE = {
"manual": 5, "manual": 5,
} }
PATH_SCOPED_NODE_KINDS = {"lockfile"} PATH_SCOPED_NODE_KINDS = {
"container-build",
"deployment-service",
"domain-name",
"kubernetes-config-map",
"kubernetes-cron-job",
"kubernetes-daemon-set",
"kubernetes-deployment",
"kubernetes-horizontal-pod-autoscaler",
"kubernetes-ingress",
"kubernetes-job",
"kubernetes-namespace",
"kubernetes-secret",
"kubernetes-service",
"kubernetes-stateful-set",
"lockfile",
"network-port",
"score-workload",
"service-config",
}
EVIDENCE_AGGREGATE_EDGE_TYPES = {
"exposes_port",
"opens_port",
"resolves_to",
"routes_to_port",
}
def reconcile_discovery_snapshots( def reconcile_discovery_snapshots(
@@ -186,9 +211,12 @@ def _edge_conflicts(by_key: dict[str, dict[str, Any]]) -> list[dict[str, object]
conflicts: list[dict[str, object]] = [] conflicts: list[dict[str, object]] = []
seen: dict[tuple[str, str, str], str] = {} seen: dict[tuple[str, str, str], str] = {}
for key, edge in sorted(by_key.items()): for key, edge in sorted(by_key.items()):
edge_type = str(edge.get("edge_type") or "")
if edge_type in EVIDENCE_AGGREGATE_EDGE_TYPES:
continue
match_key = ( match_key = (
str(edge.get("source_key") or ""), str(edge.get("source_key") or ""),
str(edge.get("edge_type") or ""), edge_type,
str(edge.get("target_key") or ""), str(edge.get("target_key") or ""),
) )
other = seen.get(match_key) other = seen.get(match_key)
@@ -292,11 +320,24 @@ def _path_scoped_nodes_are_distinct(left: dict[str, Any] | None, right: dict[str
right_kind = normalize_identity_part(str(right.get("kind") or "")) right_kind = normalize_identity_part(str(right.get("kind") or ""))
if left_kind != right_kind or left_kind not in PATH_SCOPED_NODE_KINDS: if left_kind != right_kind or left_kind not in PATH_SCOPED_NODE_KINDS:
return False return False
left_identities = _source_anchor_identities(left)
right_identities = _source_anchor_identities(right)
if left_identities and right_identities:
return left_identities.isdisjoint(right_identities)
left_paths = _source_anchor_paths(left) left_paths = _source_anchor_paths(left)
right_paths = _source_anchor_paths(right) right_paths = _source_anchor_paths(right)
return bool(left_paths and right_paths and left_paths.isdisjoint(right_paths)) return bool(left_paths and right_paths and left_paths.isdisjoint(right_paths))
def _source_anchor_identities(candidate: dict[str, Any]) -> set[str]:
anchors = candidate.get("source_anchors") if isinstance(candidate.get("source_anchors"), list) else []
return {
str(anchor.get("fingerprint") or "")
for anchor in anchors
if isinstance(anchor, dict) and anchor.get("fingerprint")
}
def _source_anchor_paths(candidate: dict[str, Any]) -> set[str]: def _source_anchor_paths(candidate: dict[str, Any]) -> set[str]:
anchors = candidate.get("source_anchors") if isinstance(candidate.get("source_anchors"), list) else [] anchors = candidate.get("source_anchors") if isinstance(candidate.get("source_anchors"), list) else []
return { return {

View File

@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
from urllib.parse import urlparse
import yaml import yaml
@@ -76,6 +77,14 @@ KUBERNETES_KINDS = {
"Service", "Service",
"StatefulSet", "StatefulSet",
} }
DEFAULT_SCHEME_PORTS = {
"http": 80,
"https": 443,
"postgres": 5432,
"postgresql": 5432,
"redis": 6379,
}
COMPOSE_DOMAIN_LABELS = {"VIRTUAL_HOST", "LETSENCRYPT_HOST"}
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -520,6 +529,25 @@ def _extract_fabric_declarations(context: ScanContext) -> None:
_add_declaration_edge(context, scope, provenance, anchor, keys_by_id.get(dependency, ""), keys_by_id, provider, "binds") _add_declaration_edge(context, scope, provenance, anchor, keys_by_id.get(dependency, ""), keys_by_id, provider, "binds")
if dependency and interface: if dependency and interface:
_add_declaration_edge(context, scope, provenance, anchor, keys_by_id.get(dependency, ""), keys_by_id, interface, "uses_interface") _add_declaration_edge(context, scope, provenance, anchor, keys_by_id.get(dependency, ""), keys_by_id, interface, "uses_interface")
elif kind == "InterfaceDeclaration":
endpoint = spec.get("endpoint") if isinstance(spec.get("endpoint"), dict) else {}
endpoint_url = str(endpoint.get("url") or "").strip()
if endpoint_url:
_add_url_runtime_endpoint(
context,
scope,
provenance,
anchor,
source_key,
endpoint_url,
server_type="declared-endpoint",
attributes={
"interface_id": graph_id,
"service_id": str(spec.get("service_id") or ""),
"source": "fabric-declaration",
},
confidence=0.95,
)
def _add_declaration_edge( def _add_declaration_edge(
@@ -829,6 +857,39 @@ def _extract_compose(context: ScanContext) -> None:
source_anchor=anchor, source_anchor=anchor,
confidence=0.9, confidence=0.9,
) )
port_bindings = _compose_port_bindings(service)
domains = _compose_domains(service)
for binding in port_bindings:
port_key = _add_runtime_endpoint(
context,
scope,
provenance,
anchor,
deployment_key,
server_host=binding["host"],
port=binding["published_port"],
protocol=binding["protocol"],
domain="",
server_type="compose-host",
attributes={
"orchestrator": "docker-compose",
"service_name": str(service_name),
"target_port": binding.get("target_port"),
"source": "docker-compose",
},
confidence=0.85,
)
for domain in domains:
_add_domain_route(
context,
scope,
provenance,
anchor,
domain,
port_key,
binding["host"],
confidence=0.8,
)
def _extract_api_contracts(context: ScanContext) -> None: def _extract_api_contracts(context: ScanContext) -> None:
@@ -956,7 +1017,7 @@ def _extract_kubernetes_manifests(context: ScanContext) -> None:
replacement_scope=scope, replacement_scope=scope,
provenance=provenance, provenance=provenance,
source_anchor=anchor, source_anchor=anchor,
aliases=[name, relpath], aliases=[name],
attributes={ attributes={
"api_version": document.get("apiVersion") or "", "api_version": document.get("apiVersion") or "",
"manifest_kind": kind, "manifest_kind": kind,
@@ -974,6 +1035,28 @@ def _extract_kubernetes_manifests(context: ScanContext) -> None:
source_anchor=anchor, source_anchor=anchor,
confidence=0.85, confidence=0.85,
) )
if kind == "Service":
_add_kubernetes_service_runtime(
context,
scope,
provenance,
anchor,
manifest_key,
name,
metadata,
document,
)
elif kind == "Ingress":
_add_kubernetes_ingress_runtime(
context,
scope,
provenance,
anchor,
manifest_key,
name,
metadata,
document,
)
def _extract_service_configs(context: ScanContext) -> None: def _extract_service_configs(context: ScanContext) -> None:
@@ -1014,6 +1097,429 @@ def _extract_service_configs(context: ScanContext) -> None:
) )
def _add_runtime_endpoint(
context: ScanContext,
scope: str,
provenance: dict[str, object],
anchor: dict[str, object],
source_key: str,
*,
server_host: str,
port: object,
protocol: str = "tcp",
domain: str = "",
server_type: str,
attributes: dict[str, object] | None = None,
confidence: float = 0.8,
) -> str:
host = _normalize_host(server_host)
port_number = _int_value(port)
protocol_value = _normalize_protocol(protocol)
if not host or port_number is None:
return ""
server_key = discovery_stable_key(context.repo_slug, "Server", host)
context.accumulator.add_node(
stable_key=server_key,
kind="Server",
label=host,
replacement_scope=scope,
provenance=provenance,
source_anchor=anchor,
aliases=[host],
attributes={
"host": host,
"server_type": server_type,
**(attributes or {}),
},
confidence=confidence,
)
port_label = f"{host}:{port_number}/{protocol_value}"
port_key = discovery_stable_key(context.repo_slug, "NetworkPort", port_label)
context.accumulator.add_node(
stable_key=port_key,
kind="NetworkPort",
label=port_label,
replacement_scope=scope,
provenance=provenance,
source_anchor=anchor,
aliases=[port_label],
attributes={
"host": host,
"port": port_number,
"protocol": protocol_value,
**(attributes or {}),
},
confidence=confidence,
)
context.accumulator.add_edge(
edge_type="opens_port",
source_key=server_key,
target_key=port_key,
replacement_scope=scope,
provenance=provenance,
source_anchor=anchor,
confidence=confidence,
)
if source_key:
context.accumulator.add_edge(
edge_type="exposes_port",
source_key=source_key,
target_key=port_key,
replacement_scope=scope,
provenance=provenance,
source_anchor=anchor,
confidence=confidence,
)
route_domain = _normalize_domain(domain)
if route_domain:
_add_domain_route(context, scope, provenance, anchor, route_domain, port_key, host, confidence=confidence)
elif _looks_like_domain(host):
_add_domain_route(context, scope, provenance, anchor, host, port_key, host, confidence=confidence)
return port_key
def _add_url_runtime_endpoint(
context: ScanContext,
scope: str,
provenance: dict[str, object],
anchor: dict[str, object],
source_key: str,
url: str,
*,
server_type: str,
attributes: dict[str, object] | None = None,
confidence: float = 0.8,
) -> str:
endpoint = _parse_endpoint_url(url)
if not endpoint:
return ""
host, port, scheme = endpoint
return _add_runtime_endpoint(
context,
scope,
provenance,
anchor,
source_key,
server_host=host,
port=port,
protocol="tcp",
domain=host if _looks_like_domain(host) else "",
server_type=server_type,
attributes={
"endpoint_url": url,
"scheme": scheme,
**(attributes or {}),
},
confidence=confidence,
)
def _add_domain_route(
context: ScanContext,
scope: str,
provenance: dict[str, object],
anchor: dict[str, object],
domain: str,
port_key: str,
server_host: 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",
label=domain_value,
replacement_scope=scope,
provenance=provenance,
source_anchor=anchor,
aliases=[domain_value],
attributes={"domain": domain_value},
confidence=confidence,
)
if port_key:
context.accumulator.add_edge(
edge_type="routes_to_port",
source_key=domain_key,
target_key=port_key,
replacement_scope=scope,
provenance=provenance,
source_anchor=anchor,
confidence=confidence,
)
if server_host:
context.accumulator.add_edge(
edge_type="resolves_to",
source_key=domain_key,
target_key=server_key,
replacement_scope=scope,
provenance=provenance,
source_anchor=anchor,
confidence=confidence,
)
def _compose_port_bindings(service: dict[str, Any]) -> list[dict[str, object]]:
ports = service.get("ports")
if not isinstance(ports, list):
return []
bindings: list[dict[str, object]] = []
for item in ports:
binding = _parse_compose_port(item)
if binding:
bindings.append(binding)
return bindings
def _parse_compose_port(item: object) -> dict[str, object] | None:
if isinstance(item, dict):
published = _int_value(item.get("published") or item.get("published_port"))
target = _int_value(item.get("target") or item.get("target_port"))
port = published or target
if port is None:
return None
return {
"host": _normalize_host(str(item.get("host_ip") or item.get("host") or "localhost")),
"published_port": port,
"target_port": target or port,
"protocol": _normalize_protocol(str(item.get("protocol") or "tcp")),
}
if not isinstance(item, str):
return None
value, _, protocol_suffix = item.partition("/")
protocol = _normalize_protocol(protocol_suffix or "tcp")
parts = value.split(":")
if len(parts) == 1:
port = _int_value(parts[0])
host = "localhost"
target = port
elif len(parts) == 2:
host = "localhost"
port = _int_value(parts[0])
target = _int_value(parts[1])
else:
host = _normalize_host(parts[-3] or "localhost")
port = _int_value(parts[-2])
target = _int_value(parts[-1])
if port is None:
return None
return {
"host": host,
"published_port": port,
"target_port": target or port,
"protocol": protocol,
}
def _compose_domains(service: dict[str, Any]) -> list[str]:
labels = service.get("labels")
pairs: list[tuple[str, str]] = []
if isinstance(labels, dict):
pairs.extend((str(key), str(value)) for key, value in labels.items())
elif isinstance(labels, list):
for label in labels:
text = str(label)
key, separator, value = text.partition("=")
pairs.append((key, value if separator else ""))
domains: list[str] = []
for key, value in pairs:
key_name = key.strip()
if key_name.upper() in COMPOSE_DOMAIN_LABELS:
domains.extend(_split_domains(value))
domains.extend(_host_rule_domains(value or key_name))
return _unique_strings(_normalize_domain(domain) for domain in domains)
def _host_rule_domains(value: str) -> list[str]:
domains: list[str] = []
for match in re.finditer(r"Host\(([^)]*)\)", value):
body = match.group(1)
for part in body.split(","):
domain = part.strip().strip("`'\" ")
if domain:
domains.append(domain)
return domains
def _split_domains(value: str) -> list[str]:
return [domain.strip() for domain in re.split(r"[,;\s]+", value) if domain.strip()]
def _add_kubernetes_service_runtime(
context: ScanContext,
scope: str,
provenance: dict[str, object],
anchor: dict[str, object],
source_key: str,
name: str,
metadata: dict[str, Any],
document: dict[str, Any],
) -> None:
namespace = str(metadata.get("namespace") or "default")
service_host = f"{name}.{namespace}.svc.cluster.local"
spec = document.get("spec") if isinstance(document.get("spec"), dict) else {}
service_type = str(spec.get("type") or "ClusterIP")
ports = spec.get("ports") if isinstance(spec.get("ports"), list) else []
for port_entry in ports:
if not isinstance(port_entry, dict):
continue
service_port = _int_value(port_entry.get("port"))
if service_port is None:
continue
_add_runtime_endpoint(
context,
scope,
provenance,
anchor,
source_key,
server_host=service_host,
port=service_port,
protocol=str(port_entry.get("protocol") or "tcp"),
domain=service_host,
server_type="kubernetes-service-dns",
attributes={
"namespace": namespace,
"service_name": name,
"service_type": service_type,
"service_port": service_port,
"target_port": port_entry.get("targetPort") or service_port,
"source": "kubernetes-service",
},
confidence=0.85,
)
def _add_kubernetes_ingress_runtime(
context: ScanContext,
scope: str,
provenance: dict[str, object],
anchor: dict[str, object],
source_key: str,
name: str,
metadata: dict[str, Any],
document: dict[str, Any],
) -> None:
namespace = str(metadata.get("namespace") or "default")
spec = document.get("spec") if isinstance(document.get("spec"), dict) else {}
tls_hosts = {
_normalize_domain(host)
for tls in spec.get("tls", [])
if isinstance(tls, dict)
for host in _string_list(tls.get("hosts"))
}
for rule in spec.get("rules", []) if isinstance(spec.get("rules"), list) else []:
if not isinstance(rule, dict):
continue
domain = _normalize_domain(str(rule.get("host") or ""))
http = rule.get("http") if isinstance(rule.get("http"), dict) else {}
paths = http.get("paths") if isinstance(http.get("paths"), list) else []
for path_rule in paths:
backend = path_rule.get("backend") if isinstance(path_rule, dict) else {}
service = backend.get("service") if isinstance(backend, dict) and isinstance(backend.get("service"), dict) else {}
service_name = str(service.get("name") or "")
port_spec = service.get("port") if isinstance(service.get("port"), dict) else {}
service_port = _int_value(port_spec.get("number"))
if not service_name or service_port is None:
continue
service_host = f"{service_name}.{namespace}.svc.cluster.local"
_add_runtime_endpoint(
context,
scope,
provenance,
anchor,
source_key,
server_host=service_host,
port=service_port,
protocol="tcp",
domain=domain,
server_type="kubernetes-service-dns",
attributes={
"namespace": namespace,
"ingress_name": name,
"backend_service": service_name,
"service_port": service_port,
"tls": domain in tls_hosts,
"source": "kubernetes-ingress",
},
confidence=0.8,
)
for host in sorted(tls_hosts):
_add_runtime_endpoint(
context,
scope,
provenance,
anchor,
source_key,
server_host=host,
port=443,
protocol="tcp",
domain=host,
server_type="ingress-host",
attributes={"namespace": namespace, "ingress_name": name, "scheme": "https", "source": "kubernetes-ingress-tls"},
confidence=0.75,
)
def _parse_endpoint_url(url: str) -> tuple[str, int, str] | None:
text = url.strip()
if not text:
return None
parsed = urlparse(text if "://" in text else f"//{text}", scheme="")
host = _normalize_host(parsed.hostname or parsed.netloc or parsed.path.split("/", 1)[0])
scheme = str(parsed.scheme or "").lower()
try:
port = parsed.port
except ValueError:
port = None
port = port or DEFAULT_SCHEME_PORTS.get(scheme)
if not host or port is None:
return None
return host, port, scheme
def _normalize_host(value: str) -> str:
host = str(value or "").strip().lower()
if host in {"0.0.0.0", "::", ""}:
return "localhost" if host else ""
return host.strip("[]")
def _normalize_domain(value: str) -> str:
return str(value or "").strip().strip(".").lower()
def _normalize_protocol(value: str) -> str:
protocol = str(value or "tcp").strip().lower()
return protocol or "tcp"
def _looks_like_domain(host: str) -> bool:
value = _normalize_domain(host)
if not value or value == "localhost":
return False
if re.fullmatch(r"[0-9.]+", value):
return False
return "." in value
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 not text or not re.fullmatch(r"\d+", text):
return None
return int(text)
def _source_anchor( def _source_anchor(
source_kind: str, source_kind: str,
path: str, path: str,

View File

@@ -40,6 +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", "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[("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"
assert nodes_by_label[("DomainName", "api.fixture.test")]["attributes"]["domain"] == "api.fixture.test"
assert nodes_by_label[("DomainName", "api.k8s.fixture.test")]["attributes"]["domain"] == "api.k8s.fixture.test"
assert nodes_by_label[("DomainName", "declared.fixture.test")]["attributes"]["domain"] == "declared.fixture.test"
edge_types = {edge["edge_type"] for edge in candidates["edges"]} edge_types = {edge["edge_type"] for edge in candidates["edges"]}
assert edge_types >= { assert edge_types >= {
@@ -53,6 +62,10 @@ def test_scan_repo_emits_schema_valid_deterministic_snapshot(tmp_path: Path) ->
"uses_config", "uses_config",
"provides", "provides",
"exposes", "exposes",
"opens_port",
"exposes_port",
"routes_to_port",
"resolves_to",
} }
assert {attribute["name"] for attribute in candidates["attributes"]} >= { assert {attribute["name"] for attribute in candidates["attributes"]} >= {
"readme_title", "readme_title",
@@ -147,7 +160,10 @@ services:
api: api:
build: . build: .
ports: ports:
- "8080:8080" - "127.0.0.1:8080:8080"
labels:
- "traefik.http.routers.fixture.rule=Host(`api.fixture.test`)"
- "VIRTUAL_HOST=api-alt.fixture.test"
""".lstrip(), """.lstrip(),
) )
_write( _write(
@@ -236,6 +252,49 @@ metadata:
spec: spec:
interface_type: http-api interface_type: http-api
lifecycle: active lifecycle: active
service_id: fixture.api
endpoint:
url: https://declared.fixture.test:9443/api
""".lstrip(),
)
_write(
repo / "deploy" / "service.yaml",
"""
apiVersion: v1
kind: Service
metadata:
name: fixture-api
namespace: testing
spec:
ports:
- port: 8080
targetPort: 8080
protocol: TCP
""".lstrip(),
)
_write(
repo / "deploy" / "ingress.yaml",
"""
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fixture-api
namespace: testing
spec:
tls:
- hosts:
- secure.fixture.test
rules:
- host: api.k8s.fixture.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: fixture-api
port:
number: 8080
""".lstrip(), """.lstrip(),
) )
return repo return repo

View File

@@ -0,0 +1,134 @@
---
id: RAIL-FAB-WP-0014
type: workplan
title: "Runtime Topology Discovery"
domain: railiance
repo: railiance-fabric
status: finished
owner: codex
topic_slug: railiance
planning_priority: high
planning_order: 14
created: "2026-05-21"
updated: "2026-05-21"
state_hub_workstream_id: "9cb51719-3ef1-400a-916e-959b24c67b79"
---
# RAIL-FAB-WP-0014 - Runtime Topology Discovery
## Goal
Discover runtime topology before broader projection: servers, ports exposed by
services, and domain names that map onto those ports.
## Background
The current scanner already discovers deployment-like evidence from Docker
Compose, Kubernetes manifests, service configs, and Fabric declarations. The
graph explorer can infer some `Server` nodes from accepted interface endpoint
URLs, but that inference is UI-local and misses servers/domains/ports that live
in runtime manifests.
Before a full discovery/projection pass, Fabric should capture these runtime
facts as normal discovery candidates with source anchors and conservative review
state.
## Design Principles
- Keep repo-owned declaration schemas unchanged for this increment.
- Emit discovery candidates for `Server`, `NetworkPort`, and `DomainName`.
- Keep facts source-linked to Compose, Kubernetes, or Fabric declaration files.
- Do not resolve DNS or scan networks; only parse deterministic repo evidence.
- Avoid projection until the broad runtime discovery pass has been reviewed.
## Tasks
### T01 - Add Runtime Topology Test Fixture
```task
id: RAIL-FAB-WP-0014-T01
status: done
priority: high
state_hub_task_id: "714e3560-cd32-4009-8cdd-8ff410cd2725"
```
Extend scanner tests so a fixture repo exposes Compose ports, Kubernetes Service
ports, Kubernetes Ingress domains, and Fabric interface endpoint URLs.
### T02 - Discover Compose Runtime Ports And Domains
```task
id: RAIL-FAB-WP-0014-T02
status: done
priority: high
state_hub_task_id: "20923f1d-6bf7-4f59-a9e5-4898d6f0a699"
```
Extract `Server`, `NetworkPort`, and `DomainName` candidates from Compose
published ports and common domain labels such as Traefik `Host(...)`,
`VIRTUAL_HOST`, and `LETSENCRYPT_HOST`.
### T03 - Discover Kubernetes Runtime Ports And Domains
```task
id: RAIL-FAB-WP-0014-T03
status: done
priority: high
state_hub_task_id: "a7664f5e-4530-413d-a1b1-2a1702ac9763"
```
Extract ports from Kubernetes `Service` manifests and domains from `Ingress`
rules/TLS hosts, linking domains to the relevant ingress/service/port evidence
where possible.
### T04 - Discover Fabric Endpoint Runtime Facts
```task
id: RAIL-FAB-WP-0014-T04
status: done
priority: medium
state_hub_task_id: "4f5e5c34-453c-4c3b-969e-80d5d2b9b370"
```
Extract server/domain/port candidates from `InterfaceDeclaration` endpoint URLs
so declared HTTP/database endpoints participate in the same runtime topology
view.
### T05 - Verify Broad Runtime Discovery
```task
id: RAIL-FAB-WP-0014-T05
status: done
priority: high
state_hub_task_id: "4aae0fa0-39a4-401c-ad5f-8d0f7647cfb6"
```
Run the deterministic tests and a broad ingest-only rescan. Confirm server,
port, and domain candidates appear without duplicate conflicts or review-only
blockers.
Verification result:
- `python3 -m pytest` passed with 33 tests.
- Broad ingest-only rescan completed with 35 repos scanned, 28 unchanged, 7
changed, 0 retired, 0 conflicted, 0 review required, and 0 errors.
- Report:
`registry/.fabric-discovery/reports/2026-05-20t222151z-deterministic.rescan-report.json`.
- Latest discovery snapshots now include 20 `Server`, 26 `NetworkPort`, and 14
`DomainName` candidates across `flex-auth`, `net-kingdom`, `railiance-apps`,
`railiance-cluster`, `railiance-fabric`, `repo-scoping`, and `state-hub`.
- `registry rescan-status --review-only --json` shows 0 review-required repos.
Projection recommendation:
- Keep this runtime topology as ingested discovery state for review.
- Do not project candidate-only topology into accepted graph snapshots until we
have reviewed the server/domain/port inventory in the graph explorer or a
focused report.
## Close Criteria
- Scanner tests validate server, network port, and domain candidates.
- Runtime topology candidates are schema-valid discovery output.
- Broad ingest-only rescan reports no scanner errors.
- State Hub records the outcome and the next projection recommendation.