generated from coulomb/repo-seed
Discover runtime topology facts
This commit is contained in:
@@ -16,7 +16,32 @@ ORIGIN_PRECEDENCE = {
|
||||
"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(
|
||||
@@ -186,9 +211,12 @@ def _edge_conflicts(by_key: dict[str, dict[str, Any]]) -> list[dict[str, object]
|
||||
conflicts: list[dict[str, object]] = []
|
||||
seen: dict[tuple[str, str, str], str] = {}
|
||||
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 = (
|
||||
str(edge.get("source_key") or ""),
|
||||
str(edge.get("edge_type") or ""),
|
||||
edge_type,
|
||||
str(edge.get("target_key") or ""),
|
||||
)
|
||||
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 ""))
|
||||
if left_kind != right_kind or left_kind not in PATH_SCOPED_NODE_KINDS:
|
||||
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)
|
||||
right_paths = _source_anchor_paths(right)
|
||||
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]:
|
||||
anchors = candidate.get("source_anchors") if isinstance(candidate.get("source_anchors"), list) else []
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -76,6 +77,14 @@ KUBERNETES_KINDS = {
|
||||
"Service",
|
||||
"StatefulSet",
|
||||
}
|
||||
DEFAULT_SCHEME_PORTS = {
|
||||
"http": 80,
|
||||
"https": 443,
|
||||
"postgres": 5432,
|
||||
"postgresql": 5432,
|
||||
"redis": 6379,
|
||||
}
|
||||
COMPOSE_DOMAIN_LABELS = {"VIRTUAL_HOST", "LETSENCRYPT_HOST"}
|
||||
|
||||
|
||||
@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")
|
||||
if dependency and 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(
|
||||
@@ -829,6 +857,39 @@ def _extract_compose(context: ScanContext) -> None:
|
||||
source_anchor=anchor,
|
||||
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:
|
||||
@@ -956,7 +1017,7 @@ def _extract_kubernetes_manifests(context: ScanContext) -> None:
|
||||
replacement_scope=scope,
|
||||
provenance=provenance,
|
||||
source_anchor=anchor,
|
||||
aliases=[name, relpath],
|
||||
aliases=[name],
|
||||
attributes={
|
||||
"api_version": document.get("apiVersion") or "",
|
||||
"manifest_kind": kind,
|
||||
@@ -974,6 +1035,28 @@ def _extract_kubernetes_manifests(context: ScanContext) -> None:
|
||||
source_anchor=anchor,
|
||||
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:
|
||||
@@ -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(
|
||||
source_kind: str,
|
||||
path: str,
|
||||
|
||||
@@ -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[("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[("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"]}
|
||||
assert edge_types >= {
|
||||
@@ -53,6 +62,10 @@ def test_scan_repo_emits_schema_valid_deterministic_snapshot(tmp_path: Path) ->
|
||||
"uses_config",
|
||||
"provides",
|
||||
"exposes",
|
||||
"opens_port",
|
||||
"exposes_port",
|
||||
"routes_to_port",
|
||||
"resolves_to",
|
||||
}
|
||||
assert {attribute["name"] for attribute in candidates["attributes"]} >= {
|
||||
"readme_title",
|
||||
@@ -147,7 +160,10 @@ services:
|
||||
api:
|
||||
build: .
|
||||
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(),
|
||||
)
|
||||
_write(
|
||||
@@ -236,6 +252,49 @@ metadata:
|
||||
spec:
|
||||
interface_type: http-api
|
||||
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(),
|
||||
)
|
||||
return repo
|
||||
|
||||
134
workplans/RAIL-FAB-WP-0014-runtime-topology-discovery.md
Normal file
134
workplans/RAIL-FAB-WP-0014-runtime-topology-discovery.md
Normal 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.
|
||||
Reference in New Issue
Block a user