mirror of
https://github.com/taylanbakircioglu/flowfish.git
synced 2026-09-16 15:45:14 +00:00
7a5d4904a9
Bring the public mirror up to date with the latest developments: - L7 HTTP Path Visibility & Per-Path Edge Model (v2.7.0): real HTTP paths in the Service Map / Integration Hub / exports instead of "/". Touches event_transformer (path extraction), graph-writer per-path MERGE key + indexes, graph-query per-path counting, and the frontend. - Beyla route decoration to fix the `/*/*/*/*/*` star-explosion in API-gateway namespaces (low-cardinality route labelling). - RabbitMQ queue-declaration resilience: graph-writer and timeseries-writer tolerate a pre-existing queue whose arguments differ from the declaration (bind to the existing queue instead of crashing). - Version bump to 2.8.0 + CHANGELOG. Example hostnames/annotations use placeholder values (example.com).
3557 lines
148 KiB
Python
3557 lines
148 KiB
Python
"""Graph Database Query Engine - Neo4j Implementation"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from collections import defaultdict
|
|
from fnmatch import fnmatch
|
|
from typing import Dict, Any, List, Optional, Tuple
|
|
from neo4j import GraphDatabase, Driver, Session, Result
|
|
from neo4j.exceptions import Neo4jError, ServiceUnavailable
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Module-level annotation noise filter helpers (audit v3 — extracted from nested
|
|
# scope so the same filter list is shared between the L4 dependency_summary path
|
|
# and the new L7 dependency_summary annotation filter. Pure functions, stateless.
|
|
# Audit B-22 / E-10).
|
|
_NOISE_ANNOTATION_PREFIXES: Tuple[str, ...] = (
|
|
'kubectl.kubernetes.io/',
|
|
'kubernetes.io/',
|
|
'openshift.io/',
|
|
'openshift.openshift.io/',
|
|
'k8s.v1.cni.cncf.io/',
|
|
'k8s.ovn.org/',
|
|
'seccomp.security.alpha.kubernetes.io/',
|
|
)
|
|
|
|
|
|
def _filter_summary_annotations(ann: Optional[dict]) -> dict:
|
|
"""Drop infrastructure/noise annotations and oversize values (>=500 chars).
|
|
|
|
Mirrors the previous nested helper inside the L4 dependency_summary
|
|
aggregator so L4 and L7 summary responses agree on what counts as
|
|
operator-visible annotation metadata.
|
|
"""
|
|
if not ann or not isinstance(ann, dict):
|
|
return ann or {}
|
|
return {
|
|
k: v for k, v in ann.items()
|
|
if not any(k.startswith(p) for p in _NOISE_ANNOTATION_PREFIXES)
|
|
and len(str(v)) < 500
|
|
}
|
|
|
|
|
|
def _parse_metadata_field(raw) -> dict:
|
|
"""Parse a Neo4j-stored labels/annotations field (JSON string or dict).
|
|
|
|
L7Workload nodes persist labels/annotations as JSON-encoded strings
|
|
(services/graph-writer/app/l7_graph_builder.py json.dumps), but L4
|
|
Workload nodes may already arrive as dicts. We tolerate both forms and
|
|
fall back to {} on malformed JSON so a single corrupt row never breaks
|
|
a filter sweep.
|
|
"""
|
|
if raw is None:
|
|
return {}
|
|
if isinstance(raw, dict):
|
|
return raw
|
|
if isinstance(raw, str):
|
|
try:
|
|
parsed = json.loads(raw)
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def _glob_match_metadata(
|
|
metadata: dict,
|
|
key: Optional[str],
|
|
value: Optional[str],
|
|
) -> bool:
|
|
"""Test whether ``metadata`` contains an entry matching key/value.
|
|
|
|
Mirrors the L4 ``find_pod_dependencies`` post-filter semantics so the new
|
|
L7 filter path behaves identically (audit B-2 / E-13):
|
|
* No key → match (filter inactive).
|
|
* Key has glob (``*``/``?``) → fnmatch against every key.
|
|
* Value empty or ``*`` → any value matches once key is found.
|
|
* Value has glob → fnmatch against the stringified value.
|
|
* Otherwise → exact equality.
|
|
"""
|
|
if not key:
|
|
return True
|
|
key_has_glob = '*' in key or '?' in key
|
|
if key_has_glob:
|
|
hit_keys = [k for k in metadata if fnmatch(k, key)]
|
|
else:
|
|
hit_keys = [key] if key in metadata else []
|
|
if not hit_keys:
|
|
return False
|
|
if not value or value == '*':
|
|
return True
|
|
value_has_glob = '*' in value or '?' in value
|
|
for k in hit_keys:
|
|
v = str(metadata[k])
|
|
if value_has_glob:
|
|
if fnmatch(v, value):
|
|
return True
|
|
elif v == value:
|
|
return True
|
|
return False
|
|
|
|
|
|
class GraphQueryEngine:
|
|
"""Neo4j graph database query engine"""
|
|
|
|
def __init__(self):
|
|
self.driver: Optional[Driver] = None
|
|
self.database = settings.neo4j_database
|
|
try:
|
|
self._connect()
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ Neo4j connection failed (will retry on first query): {e}")
|
|
|
|
def _connect(self):
|
|
"""Connect to Neo4j database"""
|
|
try:
|
|
self.driver = GraphDatabase.driver(
|
|
settings.neo4j_bolt_uri,
|
|
auth=(settings.neo4j_user, settings.neo4j_password),
|
|
max_connection_pool_size=10,
|
|
connection_timeout=settings.query_timeout,
|
|
max_transaction_retry_time=settings.query_timeout
|
|
)
|
|
|
|
# Verify connectivity
|
|
self.driver.verify_connectivity()
|
|
|
|
logger.info(f"✅ Connected to Neo4j: {settings.neo4j_bolt_uri} (database: {self.database})")
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to connect to Neo4j: {e}")
|
|
raise
|
|
|
|
def execute_query(self, query: str, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
"""Execute a Cypher query and return results"""
|
|
if not self.driver:
|
|
try:
|
|
self._connect()
|
|
except Exception as e:
|
|
return {"success": False, "error": f"Connection failed: {str(e)}"}
|
|
|
|
try:
|
|
with self.driver.session(database=self.database) as session:
|
|
result = session.run(query, parameters or {})
|
|
|
|
# Convert result to list of dictionaries
|
|
records = []
|
|
for record in result:
|
|
records.append(dict(record))
|
|
|
|
return {
|
|
"success": True,
|
|
"data": records,
|
|
"count": len(records)
|
|
}
|
|
|
|
except Neo4jError as e:
|
|
logger.error(f"❌ Neo4j query error: {e}")
|
|
return {
|
|
"success": False,
|
|
"error": f"Query failed: {e.message}",
|
|
"code": e.code
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"❌ Unexpected error: {e}")
|
|
return {
|
|
"success": False,
|
|
"error": str(e)
|
|
}
|
|
|
|
def get_workload_dependencies(self, workload_id: str, depth: int = 1) -> Dict[str, Any]:
|
|
"""Get dependencies of a workload (downstream)"""
|
|
query = """
|
|
MATCH path = (w:Workload {id: $workload_id})-[r*1..$depth]->(dep)
|
|
WHERE ALL(rel IN relationships(path) WHERE rel.is_active = true)
|
|
RETURN
|
|
w.id AS source_id,
|
|
w.name AS source_name,
|
|
w.kind AS source_kind,
|
|
[node IN nodes(path)[1..] | {
|
|
id: node.id,
|
|
name: node.name,
|
|
kind: node.kind,
|
|
namespace: node.namespace
|
|
}] AS dependencies,
|
|
[rel IN relationships(path) | type(rel)] AS relationship_types,
|
|
length(path) AS path_length
|
|
ORDER BY path_length
|
|
LIMIT 100
|
|
"""
|
|
|
|
return self.execute_query(query, {"workload_id": workload_id, "depth": depth})
|
|
|
|
def get_workload_dependents(self, workload_id: str, depth: int = 1) -> Dict[str, Any]:
|
|
"""Get dependents of a workload (upstream)"""
|
|
query = """
|
|
MATCH path = (dep)-[r*1..$depth]->(w:Workload {id: $workload_id})
|
|
WHERE ALL(rel IN relationships(path) WHERE rel.is_active = true)
|
|
RETURN
|
|
w.id AS target_id,
|
|
w.name AS target_name,
|
|
w.kind AS target_kind,
|
|
[node IN nodes(path)[..-1] | {
|
|
id: node.id,
|
|
name: node.name,
|
|
kind: node.kind,
|
|
namespace: node.namespace
|
|
}] AS dependents,
|
|
[rel IN relationships(path) | type(rel)] AS relationship_types,
|
|
length(path) AS path_length
|
|
ORDER BY path_length
|
|
LIMIT 100
|
|
"""
|
|
|
|
return self.execute_query(query, {"workload_id": workload_id, "depth": depth})
|
|
|
|
def get_communications(
|
|
self,
|
|
source_id: Optional[str] = None,
|
|
destination_id: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
protocol: Optional[str] = None,
|
|
analysis_id: Optional[str] = None,
|
|
cluster_id: Optional[str] = None,
|
|
start_time: Optional[str] = None,
|
|
end_time: Optional[str] = None,
|
|
limit: int = 100
|
|
) -> Dict[str, Any]:
|
|
"""Get communications between workloads"""
|
|
|
|
conditions = []
|
|
params = {"limit": limit}
|
|
|
|
# Multi-cluster support: analysis_id can be in format '{id}' or '{id}-{cluster_id}'
|
|
# Filter by analysis_id if provided - match both single and multi-cluster formats
|
|
if analysis_id:
|
|
analysis_id_str = str(analysis_id)
|
|
analysis_id_prefix = f"{analysis_id_str}-"
|
|
conditions.append(
|
|
"(comm.analysis_id = $analysis_id OR comm.analysis_id STARTS WITH $analysis_id_prefix OR "
|
|
"src.analysis_id = $analysis_id OR src.analysis_id STARTS WITH $analysis_id_prefix)"
|
|
)
|
|
params["analysis_id"] = analysis_id_str
|
|
params["analysis_id_prefix"] = analysis_id_prefix
|
|
|
|
# Filter by cluster_id if provided (optional for multi-cluster)
|
|
if cluster_id:
|
|
conditions.append("(src.cluster_id = $cluster_id OR comm.cluster_id = $cluster_id)")
|
|
params["cluster_id"] = str(cluster_id)
|
|
|
|
if source_id:
|
|
conditions.append("src.id = $source_id")
|
|
params["source_id"] = source_id
|
|
|
|
if destination_id:
|
|
conditions.append("dst.id = $destination_id")
|
|
params["destination_id"] = destination_id
|
|
|
|
if namespace:
|
|
# Include edges where source is in namespace, OR destination is in namespace,
|
|
# OR source is in namespace AND destination is external (ExternalEndpoint)
|
|
conditions.append(
|
|
"(src.namespace = $namespace OR dst.namespace = $namespace OR "
|
|
"(src.namespace = $namespace AND (dst:ExternalEndpoint OR dst.namespace = 'external')))"
|
|
)
|
|
params["namespace"] = namespace
|
|
|
|
if protocol:
|
|
conditions.append("comm.protocol = $protocol")
|
|
params["protocol"] = protocol
|
|
|
|
# Time range filtering - filter by last_seen timestamp
|
|
# Note: last_seen is stored as epoch milliseconds (from Neo4j timestamp() function)
|
|
# We need to convert ISO datetime string to epoch ms for comparison
|
|
if start_time:
|
|
conditions.append("comm.last_seen >= datetime($start_time).epochMillis")
|
|
params["start_time"] = start_time
|
|
|
|
if end_time:
|
|
conditions.append("comm.last_seen <= datetime($end_time).epochMillis")
|
|
params["end_time"] = end_time
|
|
|
|
# Build WHERE clause
|
|
where_clause = " AND ".join(conditions) if conditions else "true"
|
|
|
|
# Query ALL communications including to ExternalEndpoints
|
|
# Note: Use same pattern as get_communication_stats (no label constraint)
|
|
# This works for both single-cluster and multi-cluster analyses
|
|
query = f"""
|
|
MATCH (src)-[comm:COMMUNICATES_WITH]->(dst)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
src.id AS source_id,
|
|
src.name AS source_name,
|
|
src.kind AS source_kind,
|
|
src.namespace AS source_namespace,
|
|
src.ip AS source_ip,
|
|
src.node AS source_node,
|
|
src.labels AS source_labels,
|
|
src.annotations AS source_annotations,
|
|
src.owner_kind AS source_owner_kind,
|
|
src.owner_name AS source_owner_name,
|
|
src.network_type AS source_network_type,
|
|
src.is_external AS source_is_external,
|
|
src.resolution_source AS source_resolution_source,
|
|
src.pod_uid AS source_pod_uid,
|
|
src.host_ip AS source_host_ip,
|
|
src.container AS source_container,
|
|
src.image AS source_image,
|
|
src.service_account AS source_service_account,
|
|
src.phase AS source_phase,
|
|
dst.id AS destination_id,
|
|
dst.name AS destination_name,
|
|
dst.kind AS destination_kind,
|
|
dst.namespace AS destination_namespace,
|
|
dst.ip AS destination_ip,
|
|
dst.node AS destination_node,
|
|
dst.labels AS destination_labels,
|
|
dst.annotations AS destination_annotations,
|
|
dst.owner_kind AS destination_owner_kind,
|
|
dst.owner_name AS destination_owner_name,
|
|
dst.network_type AS destination_network_type,
|
|
dst.is_external AS destination_is_external,
|
|
dst.resolution_source AS destination_resolution_source,
|
|
dst.pod_uid AS destination_pod_uid,
|
|
dst.host_ip AS destination_host_ip,
|
|
dst.container AS destination_container,
|
|
dst.image AS destination_image,
|
|
dst.service_account AS destination_service_account,
|
|
dst.phase AS destination_phase,
|
|
// Per-node cluster_id projection. Without this the backend
|
|
// transformer fell back to the request-level `cluster_id`
|
|
// parameter, which is empty on multi-cluster queries -> every
|
|
// edge then carried `cluster_id="1"` and the Network Map
|
|
// collapsed all clusters into one. Coerce via toString in
|
|
// case Neo4j stored it as integer in older datasets.
|
|
toString(src.cluster_id) AS source_cluster_id,
|
|
toString(dst.cluster_id) AS destination_cluster_id,
|
|
comm.protocol AS protocol,
|
|
comm.destination_port AS destination_port,
|
|
comm.port AS port,
|
|
comm.request_count AS request_count,
|
|
comm.bytes_transferred AS bytes_transferred,
|
|
comm.avg_latency_ms AS avg_latency_ms,
|
|
comm.risk_level AS risk_level,
|
|
comm.risk_score AS risk_score,
|
|
comm.first_seen AS first_seen,
|
|
comm.last_seen AS last_seen,
|
|
comm.analysis_id AS analysis_id,
|
|
comm.error_count AS error_count,
|
|
comm.retransmit_count AS retransmit_count,
|
|
comm.last_error_type AS last_error_type
|
|
ORDER BY comm.last_seen DESC
|
|
LIMIT $limit
|
|
"""
|
|
|
|
logger.info(f"[GET_COMMS] Executing query with params: {params}")
|
|
logger.info(f"[GET_COMMS] WHERE clause: {where_clause}")
|
|
|
|
result = self.execute_query(query, params)
|
|
|
|
data_count = len(result.get("data", [])) if result else 0
|
|
logger.info(f"[GET_COMMS] Query result: success={result.get('success')}, data_count={data_count}")
|
|
|
|
return result
|
|
|
|
def get_communication_count(
|
|
self,
|
|
analysis_id: Optional[str] = None,
|
|
cluster_id: Optional[str] = None,
|
|
namespace: Optional[str] = None
|
|
) -> int:
|
|
"""Get total count of communications without limit (for smart edge limit calculation)"""
|
|
|
|
conditions = []
|
|
params = {}
|
|
|
|
# Multi-cluster support: analysis_id can be in format '{id}' or '{id}-{cluster_id}'
|
|
if analysis_id:
|
|
analysis_id_str = str(analysis_id)
|
|
analysis_id_prefix = f"{analysis_id_str}-"
|
|
conditions.append(
|
|
"(comm.analysis_id = $analysis_id OR comm.analysis_id STARTS WITH $analysis_id_prefix OR "
|
|
"src.analysis_id = $analysis_id OR src.analysis_id STARTS WITH $analysis_id_prefix)"
|
|
)
|
|
params["analysis_id"] = analysis_id_str
|
|
params["analysis_id_prefix"] = analysis_id_prefix
|
|
|
|
# Filter by cluster_id if provided
|
|
if cluster_id:
|
|
conditions.append("(src.cluster_id = $cluster_id OR comm.cluster_id = $cluster_id)")
|
|
params["cluster_id"] = str(cluster_id)
|
|
|
|
if namespace:
|
|
conditions.append(
|
|
"(src.namespace = $namespace OR dst.namespace = $namespace OR "
|
|
"(src.namespace = $namespace AND (dst:ExternalEndpoint OR dst.namespace = 'external')))"
|
|
)
|
|
params["namespace"] = namespace
|
|
|
|
# Build WHERE clause
|
|
where_clause = " AND ".join(conditions) if conditions else "true"
|
|
|
|
# COUNT query - no limit needed
|
|
# Use same pattern as get_communication_stats (no label constraint)
|
|
query = f"""
|
|
MATCH (src)-[comm:COMMUNICATES_WITH]->(dst)
|
|
WHERE {where_clause}
|
|
RETURN count(comm) AS total_count
|
|
"""
|
|
|
|
result = self.execute_query(query, params)
|
|
|
|
if result.get("success") and result.get("data"):
|
|
return result["data"][0].get("total_count", 0)
|
|
return 0
|
|
|
|
def get_cross_namespace_communications(
|
|
self,
|
|
analysis_id: Optional[str] = None,
|
|
cluster_id: Optional[str] = None,
|
|
limit: int = 50
|
|
) -> Dict[str, Any]:
|
|
"""Get cross-namespace communications (potential security risk)"""
|
|
|
|
conditions = [
|
|
"src.namespace <> dst.namespace",
|
|
"comm.is_active = true",
|
|
"NOT src.namespace IN ['kube-system', 'kube-public']"
|
|
]
|
|
params = {"limit": limit}
|
|
|
|
# Multi-cluster support: match both single and multi-cluster analysis_id formats
|
|
if analysis_id:
|
|
analysis_id_str = str(analysis_id)
|
|
analysis_id_prefix = f"{analysis_id_str}-"
|
|
conditions.append("(comm.analysis_id = $analysis_id OR comm.analysis_id STARTS WITH $analysis_id_prefix)")
|
|
params["analysis_id"] = analysis_id_str
|
|
params["analysis_id_prefix"] = analysis_id_prefix
|
|
|
|
if cluster_id:
|
|
conditions.append("src.cluster_id = $cluster_id")
|
|
params["cluster_id"] = str(cluster_id)
|
|
|
|
where_clause = " AND ".join(conditions)
|
|
|
|
query = f"""
|
|
MATCH (src:Workload)-[comm:COMMUNICATES_WITH]->(dst)
|
|
WHERE (dst:Workload OR dst:ExternalEndpoint) AND {where_clause}
|
|
RETURN
|
|
src.namespace AS source_namespace,
|
|
src.name AS source_name,
|
|
COALESCE(dst.namespace, 'external') AS destination_namespace,
|
|
dst.name AS destination_name,
|
|
comm.protocol AS protocol,
|
|
COALESCE(comm.destination_port, comm.port, 0) AS port,
|
|
comm.risk_score AS risk_score,
|
|
comm.analysis_id AS analysis_id
|
|
ORDER BY comm.risk_score DESC
|
|
LIMIT $limit
|
|
"""
|
|
|
|
return self.execute_query(query, params)
|
|
|
|
def get_external_communications(
|
|
self,
|
|
namespace: Optional[str] = None,
|
|
analysis_id: Optional[str] = None,
|
|
cluster_id: Optional[str] = None,
|
|
limit: int = 50
|
|
) -> Dict[str, Any]:
|
|
"""Get external communications"""
|
|
|
|
conditions = ["comm.is_active = true"]
|
|
params = {"limit": limit}
|
|
|
|
if namespace:
|
|
conditions.append("src.namespace = $namespace")
|
|
params["namespace"] = namespace
|
|
|
|
# Multi-cluster support: match both single and multi-cluster analysis_id formats
|
|
if analysis_id:
|
|
analysis_id_str = str(analysis_id)
|
|
analysis_id_prefix = f"{analysis_id_str}-"
|
|
conditions.append("(comm.analysis_id = $analysis_id OR comm.analysis_id STARTS WITH $analysis_id_prefix)")
|
|
params["analysis_id"] = analysis_id_str
|
|
params["analysis_id_prefix"] = analysis_id_prefix
|
|
|
|
if cluster_id:
|
|
conditions.append("src.cluster_id = $cluster_id")
|
|
params["cluster_id"] = str(cluster_id)
|
|
|
|
where_clause = " AND ".join(conditions)
|
|
|
|
query = f"""
|
|
MATCH (src:Workload)-[comm:COMMUNICATES_WITH]->(ext:ExternalEndpoint)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
src.name AS source_name,
|
|
src.namespace AS source_namespace,
|
|
ext.ip_address AS external_ip,
|
|
ext.hostname AS external_hostname,
|
|
comm.destination_port AS port,
|
|
comm.protocol AS protocol,
|
|
comm.request_count AS request_count,
|
|
comm.analysis_id AS analysis_id
|
|
ORDER BY comm.last_seen DESC
|
|
LIMIT $limit
|
|
"""
|
|
|
|
return self.execute_query(query, params)
|
|
|
|
def get_high_risk_communications(
|
|
self,
|
|
analysis_id: Optional[str] = None,
|
|
cluster_id: Optional[str] = None,
|
|
limit: int = 50
|
|
) -> Dict[str, Any]:
|
|
"""Get high-risk communications"""
|
|
|
|
conditions = [
|
|
"comm.risk_level IN ['high', 'critical']",
|
|
"comm.is_active = true"
|
|
]
|
|
params = {"limit": limit}
|
|
|
|
# Multi-cluster support: match both single and multi-cluster analysis_id formats
|
|
if analysis_id:
|
|
analysis_id_str = str(analysis_id)
|
|
analysis_id_prefix = f"{analysis_id_str}-"
|
|
conditions.append("(comm.analysis_id = $analysis_id OR comm.analysis_id STARTS WITH $analysis_id_prefix)")
|
|
params["analysis_id"] = analysis_id_str
|
|
params["analysis_id_prefix"] = analysis_id_prefix
|
|
|
|
if cluster_id:
|
|
conditions.append("src.cluster_id = $cluster_id")
|
|
params["cluster_id"] = str(cluster_id)
|
|
|
|
where_clause = " AND ".join(conditions)
|
|
|
|
query = f"""
|
|
MATCH (src:Workload)-[comm:COMMUNICATES_WITH]->(dst)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
src.name AS source_name,
|
|
src.namespace AS source_namespace,
|
|
dst.name AS destination_name,
|
|
comm.protocol AS protocol,
|
|
comm.destination_port AS port,
|
|
comm.risk_level AS risk_level,
|
|
comm.risk_score AS risk_score,
|
|
comm.risk_factors AS risk_factors,
|
|
comm.analysis_id AS analysis_id
|
|
ORDER BY comm.risk_score DESC
|
|
LIMIT $limit
|
|
"""
|
|
|
|
return self.execute_query(query, params)
|
|
|
|
def get_workload_by_id(self, workload_id: str) -> Dict[str, Any]:
|
|
"""Get workload details by ID"""
|
|
query = """
|
|
MATCH (w:Workload {id: $workload_id})
|
|
RETURN w
|
|
"""
|
|
|
|
return self.execute_query(query, {"workload_id": workload_id})
|
|
|
|
def get_workloads_by_namespace(
|
|
self,
|
|
namespace: str,
|
|
kind: Optional[str] = None,
|
|
analysis_id: Optional[str] = None,
|
|
cluster_id: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Get all workloads in a namespace"""
|
|
|
|
conditions = [
|
|
"w.namespace = $namespace",
|
|
"w.is_active = true"
|
|
]
|
|
params = {"namespace": namespace}
|
|
|
|
if kind:
|
|
conditions.append("w.kind = $kind")
|
|
params["kind"] = kind
|
|
|
|
# Multi-cluster support: match both single and multi-cluster analysis_id formats
|
|
if analysis_id:
|
|
analysis_id_str = str(analysis_id)
|
|
analysis_id_prefix = f"{analysis_id_str}-"
|
|
conditions.append("(w.analysis_id = $analysis_id OR w.analysis_id STARTS WITH $analysis_id_prefix)")
|
|
params["analysis_id"] = analysis_id_str
|
|
params["analysis_id_prefix"] = analysis_id_prefix
|
|
|
|
if cluster_id:
|
|
conditions.append("w.cluster_id = $cluster_id")
|
|
params["cluster_id"] = str(cluster_id)
|
|
|
|
where_clause = " AND ".join(conditions)
|
|
|
|
query = f"""
|
|
MATCH (w:Workload)
|
|
WHERE {where_clause}
|
|
RETURN w
|
|
ORDER BY w.name
|
|
"""
|
|
|
|
return self.execute_query(query, params)
|
|
|
|
def get_workloads(
|
|
self,
|
|
analysis_id: Optional[str] = None,
|
|
cluster_id: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
kind: Optional[str] = None,
|
|
limit: int = 1000
|
|
) -> Dict[str, Any]:
|
|
"""Get workloads with optional filters"""
|
|
|
|
conditions = ["w.is_active = true"]
|
|
params = {"limit": limit}
|
|
|
|
# Multi-cluster support: match both single and multi-cluster analysis_id formats
|
|
if analysis_id:
|
|
analysis_id_str = str(analysis_id)
|
|
analysis_id_prefix = f"{analysis_id_str}-"
|
|
conditions.append("(w.analysis_id = $analysis_id OR w.analysis_id STARTS WITH $analysis_id_prefix)")
|
|
params["analysis_id"] = analysis_id_str
|
|
params["analysis_id_prefix"] = analysis_id_prefix
|
|
|
|
if cluster_id:
|
|
conditions.append("w.cluster_id = $cluster_id")
|
|
params["cluster_id"] = str(cluster_id)
|
|
|
|
if namespace:
|
|
conditions.append("w.namespace = $namespace")
|
|
params["namespace"] = namespace
|
|
|
|
if kind:
|
|
conditions.append("w.kind = $kind")
|
|
params["kind"] = kind
|
|
|
|
where_clause = " AND ".join(conditions)
|
|
|
|
query = f"""
|
|
MATCH (w:Workload)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
w.id AS id,
|
|
w.name AS name,
|
|
w.namespace AS namespace,
|
|
w.kind AS kind,
|
|
w.cluster_id AS cluster_id,
|
|
w.analysis_id AS analysis_id,
|
|
w.ip AS ip,
|
|
w.status AS status,
|
|
w.labels AS labels,
|
|
w.annotations AS annotations,
|
|
w.created_at AS created_at
|
|
ORDER BY w.namespace, w.name
|
|
LIMIT $limit
|
|
"""
|
|
|
|
result = self.execute_query(query, params)
|
|
if result.get("success") and result.get("data"):
|
|
for record in result["data"]:
|
|
for field in ("labels", "annotations"):
|
|
raw = record.get(field)
|
|
if isinstance(raw, str):
|
|
try:
|
|
record[field] = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
record[field] = {}
|
|
elif not raw:
|
|
record[field] = {}
|
|
return result
|
|
|
|
def get_dependency_graph(
|
|
self,
|
|
cluster_id: Optional[str] = None,
|
|
analysis_id: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
depth: int = 2,
|
|
search: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get dependency graph with nodes and edges for visualization
|
|
|
|
Args:
|
|
search: Optional search term (min 3 chars) to filter nodes by name, namespace,
|
|
id, ip, host_ip, or edge port.
|
|
When provided, limit is increased to ensure all matching results are returned.
|
|
|
|
Returns:
|
|
Dict with 'nodes' and 'edges' lists
|
|
"""
|
|
conditions = []
|
|
params = {}
|
|
|
|
if cluster_id:
|
|
params["cluster_id"] = str(cluster_id)
|
|
|
|
# Multi-cluster support: set up both analysis_id and prefix for pattern matching
|
|
if analysis_id:
|
|
analysis_id_str = str(analysis_id)
|
|
analysis_id_prefix = f"{analysis_id_str}-"
|
|
params["analysis_id"] = analysis_id_str
|
|
params["analysis_id_prefix"] = analysis_id_prefix
|
|
|
|
if namespace:
|
|
params["namespace"] = namespace
|
|
|
|
# Server-side search: filter by node name, namespace, id, ip, or edge port
|
|
# Only active for 3+ character searches to avoid overly broad matches
|
|
search_condition = ""
|
|
if search and len(search) >= 3:
|
|
params["search"] = search.lower()
|
|
search_condition = """
|
|
AND (
|
|
toLower(src.name) CONTAINS $search OR
|
|
toLower(src.namespace) CONTAINS $search OR
|
|
toLower(src.id) CONTAINS $search OR
|
|
toLower(coalesce(src.ip, '')) CONTAINS $search OR
|
|
toLower(coalesce(src.host_ip, '')) CONTAINS $search OR
|
|
toLower(dst.name) CONTAINS $search OR
|
|
toLower(dst.namespace) CONTAINS $search OR
|
|
toLower(dst.id) CONTAINS $search OR
|
|
toLower(coalesce(dst.ip, '')) CONTAINS $search OR
|
|
toLower(coalesce(dst.host_ip, '')) CONTAINS $search OR
|
|
toString(coalesce(r.port, 0)) CONTAINS $search
|
|
)
|
|
"""
|
|
|
|
# Build WHERE clause for edges
|
|
edge_conditions = []
|
|
if cluster_id:
|
|
edge_conditions.append("(r.cluster_id = $cluster_id)")
|
|
if analysis_id:
|
|
# Multi-cluster support: match both single and multi-cluster analysis_id formats
|
|
edge_conditions.append("(r.analysis_id = $analysis_id OR r.analysis_id STARTS WITH $analysis_id_prefix)")
|
|
if namespace:
|
|
# CRITICAL FIX: At least ONE endpoint must be in the selected namespace
|
|
# This prevents external-to-external edges that have no connection to the filtered namespace
|
|
#
|
|
# Edge is included if:
|
|
# - At least one endpoint is in the selected namespace (src OR dst)
|
|
# - AND both endpoints are either in namespace OR external (prevents cross-namespace leaks)
|
|
#
|
|
# Examples (namespace = 'flowfish'):
|
|
# - flowfish → flowfish: OK (both in namespace)
|
|
# - flowfish → external: OK (one in namespace)
|
|
# - external → flowfish: OK (one in namespace)
|
|
# - external → external: BLOCKED (neither in namespace - causes floating edges!)
|
|
edge_conditions.append(
|
|
"(src.namespace = $namespace OR dst.namespace = $namespace)"
|
|
)
|
|
|
|
edge_where = " AND ".join(edge_conditions) if edge_conditions else "true"
|
|
|
|
# When search is active, increase limit to get all matching results
|
|
# Normal: 5000 (performance), Search: 50000 (find all matches)
|
|
effective_limit = 50000 if search and len(search) >= 3 else settings.max_results
|
|
|
|
# ============================================================================
|
|
# EDGE-FIRST APPROACH: Derive nodes FROM edges
|
|
# ============================================================================
|
|
# This ensures:
|
|
# 1. Only pods with active communication in this analysis are shown
|
|
# 2. No floating edges (every edge endpoint has a node by definition)
|
|
# 3. No analysis_id filtering issues for nodes (pods from old analyses visible)
|
|
#
|
|
# Flow:
|
|
# 1. Get all edges (filtered by analysis_id + namespace + search)
|
|
# 2. Collect node IDs from edge endpoints
|
|
# 3. Fetch node details for those IDs only
|
|
# ============================================================================
|
|
|
|
# Step 1: Get all edges first
|
|
# Get COMMUNICATES_WITH edges
|
|
comm_edges_query = f"""
|
|
MATCH (src)-[r:COMMUNICATES_WITH]->(dst)
|
|
WHERE {edge_where}
|
|
{search_condition}
|
|
RETURN DISTINCT
|
|
src.id AS source_id,
|
|
dst.id AS target_id,
|
|
'COMMUNICATES_WITH' AS edge_type,
|
|
COALESCE(r.protocol, 'TCP') AS protocol,
|
|
COALESCE(r.app_protocol, r.protocol, 'TCP') AS app_protocol,
|
|
COALESCE(r.port, r.destination_port, 0) AS port,
|
|
COALESCE(r.request_count, 1) AS request_count,
|
|
COALESCE(r.error_count, 0) AS error_count,
|
|
COALESCE(r.retransmit_count, 0) AS retransmit_count,
|
|
r.last_error_type AS last_error_type
|
|
ORDER BY request_count DESC, source_id, target_id
|
|
LIMIT {effective_limit}
|
|
"""
|
|
|
|
comm_result = self.execute_query(comm_edges_query, params)
|
|
edges = comm_result.get("data", []) if comm_result.get("success") else []
|
|
|
|
logger.info(f"[EDGE_FETCH] COMMUNICATES_WITH: {len(edges)} edges")
|
|
|
|
# Get DNS query edges (DNS targets are always external)
|
|
# All DNS edges go to external endpoints, so fetch all with reasonable limit
|
|
dns_edges_query = f"""
|
|
MATCH (src)-[r:QUERIES_DNS]->(dst)
|
|
WHERE {edge_where}
|
|
{search_condition}
|
|
RETURN DISTINCT
|
|
src.id AS source_id,
|
|
dst.id AS target_id,
|
|
'QUERIES_DNS' AS edge_type,
|
|
'DNS' AS protocol,
|
|
'DNS' AS app_protocol,
|
|
53 AS port,
|
|
COALESCE(r.request_count, 1) AS request_count,
|
|
0 AS error_count,
|
|
0 AS retransmit_count,
|
|
null AS last_error_type
|
|
ORDER BY request_count DESC, source_id, target_id
|
|
LIMIT {effective_limit}
|
|
"""
|
|
|
|
dns_result = self.execute_query(dns_edges_query, params)
|
|
dns_edges = dns_result.get("data", []) if dns_result.get("success") else []
|
|
logger.info(f"[EDGE_FETCH] QUERIES_DNS: {len(dns_edges)} edges")
|
|
|
|
# Get TLS connection edges
|
|
tls_edges_query = f"""
|
|
MATCH (src)-[r:TLS_CONNECTS]->(dst)
|
|
WHERE {edge_where}
|
|
{search_condition}
|
|
RETURN DISTINCT
|
|
src.id AS source_id,
|
|
dst.id AS target_id,
|
|
'TLS_CONNECTS' AS edge_type,
|
|
'TLS' AS protocol,
|
|
'TLS' AS app_protocol,
|
|
COALESCE(r.port, r.destination_port, 443) AS port,
|
|
COALESCE(r.request_count, 1) AS request_count,
|
|
0 AS error_count,
|
|
0 AS retransmit_count,
|
|
null AS last_error_type
|
|
ORDER BY request_count DESC, source_id, target_id
|
|
LIMIT {effective_limit}
|
|
"""
|
|
|
|
tls_result = self.execute_query(tls_edges_query, params)
|
|
tls_edges = tls_result.get("data", []) if tls_result.get("success") else []
|
|
|
|
logger.info(f"[EDGE_FETCH] TLS_CONNECTS: {len(tls_edges)} edges")
|
|
|
|
# Get LISTENS_ON edges (service endpoints)
|
|
listen_edges_query = f"""
|
|
MATCH (src)-[r:LISTENS_ON]->(dst)
|
|
WHERE {edge_where}
|
|
{search_condition}
|
|
RETURN DISTINCT
|
|
src.id AS source_id,
|
|
dst.id AS target_id,
|
|
'LISTENS_ON' AS edge_type,
|
|
COALESCE(r.protocol, 'TCP') AS protocol,
|
|
COALESCE(r.app_protocol, r.protocol, 'TCP') AS app_protocol,
|
|
COALESCE(r.port, r.bind_port, 0) AS port,
|
|
COALESCE(r.request_count, 1) AS request_count,
|
|
0 AS error_count,
|
|
0 AS retransmit_count,
|
|
null AS last_error_type
|
|
ORDER BY request_count DESC, source_id, target_id
|
|
LIMIT {effective_limit}
|
|
"""
|
|
|
|
listen_result = self.execute_query(listen_edges_query, params)
|
|
listen_edges = listen_result.get("data", []) if listen_result.get("success") else []
|
|
logger.info(f"[EDGE_FETCH] LISTENS_ON: {len(listen_edges)} edges")
|
|
|
|
# Combine all edges
|
|
all_edges = edges + dns_edges + tls_edges + listen_edges
|
|
|
|
# Step 2: Collect all node IDs from edges
|
|
edge_source_ids = {e.get("source_id") for e in all_edges if e.get("source_id")}
|
|
edge_target_ids = {e.get("target_id") for e in all_edges if e.get("target_id")}
|
|
all_edge_node_ids = edge_source_ids | edge_target_ids
|
|
|
|
logger.warning(f"[GRAPH_QUERY_DEBUG] Total edges: {len(all_edges)}, unique node IDs from edges: {len(all_edge_node_ids)}")
|
|
logger.warning(f"[GRAPH_QUERY_DEBUG] Namespace filter: {namespace}, analysis_id: {analysis_id}, search: {search}, limit: {effective_limit}")
|
|
|
|
# Step 3: Fetch node details for edge endpoints only
|
|
# No analysis_id filter for nodes - we already filtered edges by analysis_id
|
|
nodes = []
|
|
if all_edge_node_ids:
|
|
node_ids_list = list(all_edge_node_ids)
|
|
nodes_query = """
|
|
MATCH (w)
|
|
WHERE w.id IN $node_ids
|
|
RETURN DISTINCT
|
|
w.id AS id,
|
|
COALESCE(w.name, 'unknown') AS name,
|
|
CASE WHEN w.owner_kind = 'Service' THEN 'Service' ELSE COALESCE(w.kind, labels(w)[0], 'Workload') END AS kind,
|
|
COALESCE(w.namespace, 'external') AS namespace,
|
|
COALESCE(w.cluster_id, '1') AS cluster_id,
|
|
COALESCE(w.status, 'unknown') AS status,
|
|
w.labels AS labels,
|
|
w.annotations AS annotations,
|
|
COALESCE(w.is_external, false) AS is_external,
|
|
w.ip AS ip,
|
|
w.host_ip AS host_ip,
|
|
w.owner_kind AS owner_kind,
|
|
w.owner_name AS owner_name,
|
|
w.node AS node,
|
|
w.network_type AS network_type,
|
|
w.resolution_source AS resolution_source,
|
|
w.pod_uid AS pod_uid,
|
|
w.container AS container,
|
|
w.image AS image,
|
|
w.service_account AS service_account,
|
|
w.phase AS phase
|
|
"""
|
|
nodes_result = self.execute_query(nodes_query, {"node_ids": node_ids_list})
|
|
nodes = nodes_result.get("data", []) if nodes_result.get("success") else []
|
|
|
|
logger.warning(f"[GRAPH_QUERY_DEBUG] Fetched {len(nodes)} nodes for {len(node_ids_list)} edge endpoints")
|
|
|
|
# Step 4: Create synthetic nodes for any missing endpoints (edge endpoints not in Neo4j)
|
|
existing_node_ids = {n.get("id") for n in nodes if n.get("id")}
|
|
missing_node_ids = all_edge_node_ids - existing_node_ids
|
|
|
|
if missing_node_ids:
|
|
logger.warning(f"[GRAPH_QUERY_DEBUG] Creating {len(missing_node_ids)} synthetic nodes for missing endpoints")
|
|
# Create synthetic nodes for missing endpoints
|
|
# Parse node ID format: analysis_id:cluster_id:namespace:workload (4-part)
|
|
# or legacy: cluster_id:namespace:workload (3-part)
|
|
for node_id in missing_node_ids:
|
|
parts = node_id.split(":", 3) # Split into max 4 parts
|
|
if len(parts) >= 4:
|
|
# New format: analysis_id:cluster_id:namespace:workload
|
|
_, node_cluster, node_ns, node_name = parts[0], parts[1], parts[2], parts[3]
|
|
elif len(parts) == 3:
|
|
node_cluster, node_ns, node_name = parts[0], parts[1], parts[2]
|
|
elif len(parts) == 2:
|
|
node_cluster, node_ns, node_name = "1", parts[0], parts[1]
|
|
else:
|
|
node_cluster, node_ns, node_name = "1", "external", node_id
|
|
|
|
# Determine network_type based on namespace
|
|
# Must match frontend NETWORK_TYPE_INFO keys exactly
|
|
network_type = None
|
|
if node_ns == "external":
|
|
network_type = "External-IP"
|
|
elif node_ns == "cluster-network":
|
|
# Could be Pod-Network or Service-Network, default to Service
|
|
network_type = "Service-Network"
|
|
elif node_ns == "internal-network":
|
|
network_type = "Internal-Network"
|
|
elif node_ns == "sdn-infrastructure":
|
|
network_type = "SDN-Gateway"
|
|
|
|
# Infer kind from namespace
|
|
if node_ns == "external":
|
|
synth_kind = "External"
|
|
elif node_ns in ("sdn-infrastructure", "cluster-network", "service-network"):
|
|
synth_kind = "Infrastructure"
|
|
elif node_ns in ("internal-network", "datacenter"):
|
|
synth_kind = "DataCenter"
|
|
else:
|
|
synth_kind = "Pod"
|
|
|
|
synthetic_node = {
|
|
"id": node_id,
|
|
"name": node_name,
|
|
"kind": synth_kind,
|
|
"namespace": node_ns,
|
|
"cluster_id": node_cluster,
|
|
"status": "unknown",
|
|
"labels": {},
|
|
"is_external": node_ns == "external",
|
|
"ip": node_name if self._is_ip_address(node_name) else None,
|
|
"host_ip": None,
|
|
"owner_kind": None,
|
|
"owner_name": None,
|
|
"node": None,
|
|
"network_type": network_type,
|
|
"resolution_source": "synthetic"
|
|
}
|
|
nodes.append(synthetic_node)
|
|
|
|
logger.warning(f"[GRAPH_QUERY_DEBUG] Created {len(missing_node_ids)} synthetic nodes")
|
|
|
|
# Final verification - all edges should have valid endpoints now
|
|
final_node_ids = {n.get("id") for n in nodes if n.get("id")}
|
|
edges_before = len(all_edges)
|
|
all_edges = [
|
|
e for e in all_edges
|
|
if e.get("source_id") in final_node_ids and e.get("target_id") in final_node_ids
|
|
]
|
|
edges_after = len(all_edges)
|
|
|
|
if edges_before != edges_after:
|
|
# This should not happen with edge-first approach, but log if it does
|
|
logger.error(f"[GRAPH_QUERY_ERROR] Filtered {edges_before - edges_after} edges - this should not happen!")
|
|
|
|
logger.warning(f"[GRAPH_QUERY_DEBUG] Final result: {len(nodes)} nodes, {len(all_edges)} edges")
|
|
|
|
# Post-process nodes: parse JSON string fields
|
|
# Neo4j stores labels/annotations as JSON string, but frontend expects object
|
|
for node in nodes:
|
|
# Parse labels from JSON string to dict
|
|
labels_raw = node.get("labels")
|
|
if labels_raw:
|
|
if isinstance(labels_raw, str):
|
|
try:
|
|
node["labels"] = json.loads(labels_raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
node["labels"] = {}
|
|
elif not isinstance(labels_raw, dict):
|
|
node["labels"] = {}
|
|
else:
|
|
node["labels"] = {}
|
|
|
|
# Parse annotations from JSON string to dict
|
|
annotations_raw = node.get("annotations")
|
|
if annotations_raw:
|
|
if isinstance(annotations_raw, str):
|
|
try:
|
|
node["annotations"] = json.loads(annotations_raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
node["annotations"] = {}
|
|
elif not isinstance(annotations_raw, dict):
|
|
node["annotations"] = {}
|
|
else:
|
|
node["annotations"] = {}
|
|
|
|
return {
|
|
"nodes": nodes,
|
|
"edges": all_edges,
|
|
"total_nodes": len(nodes),
|
|
"total_edges": len(all_edges)
|
|
}
|
|
|
|
def get_communication_stats(
|
|
self,
|
|
cluster_id: Optional[str] = None,
|
|
analysis_id: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get communication statistics including both network and DNS communications
|
|
|
|
Multi-cluster support: analysis_id can be in format '{id}' or '{id}-{cluster_id}'
|
|
"""
|
|
conditions = []
|
|
params = {}
|
|
|
|
if cluster_id:
|
|
conditions.append("r.cluster_id = $cluster_id")
|
|
params["cluster_id"] = str(cluster_id)
|
|
|
|
# Multi-cluster support: match both single and multi-cluster analysis_id formats
|
|
if analysis_id:
|
|
analysis_id_str = str(analysis_id)
|
|
analysis_id_prefix = f"{analysis_id_str}-"
|
|
conditions.append("(r.analysis_id = $analysis_id OR r.analysis_id STARTS WITH $analysis_id_prefix)")
|
|
params["analysis_id"] = analysis_id_str
|
|
params["analysis_id_prefix"] = analysis_id_prefix
|
|
|
|
where_clause = " AND ".join(conditions) if conditions else "true"
|
|
|
|
# Count COMMUNICATES_WITH edges
|
|
comm_query = f"""
|
|
MATCH (src)-[r:COMMUNICATES_WITH]->(dst)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
count(r) AS comm_count,
|
|
sum(COALESCE(r.request_count, 1)) AS comm_requests,
|
|
sum(COALESCE(r.bytes_transferred, 0)) AS bytes_transferred,
|
|
sum(COALESCE(r.error_count, 0)) AS total_errors,
|
|
sum(COALESCE(r.retransmit_count, 0)) AS total_retransmits,
|
|
count(DISTINCT src.namespace) AS src_namespaces,
|
|
count(DISTINCT dst.namespace) AS dst_namespaces
|
|
"""
|
|
|
|
comm_result = self.execute_query(comm_query, params)
|
|
|
|
# Count QUERIES_DNS edges
|
|
dns_query = f"""
|
|
MATCH (src)-[r:QUERIES_DNS]->(dst)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
count(r) AS dns_count,
|
|
sum(COALESCE(r.request_count, 1)) AS dns_requests
|
|
"""
|
|
|
|
dns_result = self.execute_query(dns_query, params)
|
|
|
|
# Combine results
|
|
comm_data = comm_result.get("data", [{}])[0] if comm_result.get("success") else {}
|
|
dns_data = dns_result.get("data", [{}])[0] if dns_result.get("success") else {}
|
|
|
|
total_communications = (comm_data.get("comm_count", 0) or 0) + (dns_data.get("dns_count", 0) or 0)
|
|
total_requests = (comm_data.get("comm_requests", 0) or 0) + (dns_data.get("dns_requests", 0) or 0)
|
|
|
|
return {
|
|
"total_communications": total_communications,
|
|
"total_request_count": total_requests,
|
|
"total_bytes_transferred": comm_data.get("bytes_transferred", 0) or 0,
|
|
"total_errors": comm_data.get("total_errors", 0) or 0,
|
|
"total_retransmits": comm_data.get("total_retransmits", 0) or 0,
|
|
"unique_namespaces": (comm_data.get("src_namespaces", 0) or 0) + (comm_data.get("dst_namespaces", 0) or 0),
|
|
"network_communications": comm_data.get("comm_count", 0) or 0,
|
|
"dns_queries": dns_data.get("dns_count", 0) or 0,
|
|
"protocol_distribution": {
|
|
"TCP": comm_data.get("comm_count", 0) or 0,
|
|
"DNS": dns_data.get("dns_count", 0) or 0
|
|
},
|
|
"risk_distribution": {},
|
|
"cluster_id": cluster_id,
|
|
"analysis_id": analysis_id
|
|
}
|
|
|
|
def _l7_match_where(
|
|
self,
|
|
analysis_id: str,
|
|
cluster_id: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
protocol: Optional[str] = None,
|
|
protocols: Optional[str] = None,
|
|
rel_alias: str = "r",
|
|
src_alias: str = "src",
|
|
) -> Tuple[str, Dict[str, Any]]:
|
|
"""Build WHERE clause for L7_COMMUNICATES_WITH patterns (multi-cluster analysis_id)."""
|
|
conditions = []
|
|
params: Dict[str, Any] = {}
|
|
aid = str(analysis_id)
|
|
params["analysis_id"] = aid
|
|
params["analysis_id_prefix"] = f"{aid}-"
|
|
conditions.append(
|
|
f"({rel_alias}.analysis_id = $analysis_id OR {rel_alias}.analysis_id STARTS WITH $analysis_id_prefix)"
|
|
)
|
|
if cluster_id:
|
|
conditions.append(f"({src_alias}.cluster = $cluster_id OR dst.cluster = $cluster_id)")
|
|
params["cluster_id"] = str(cluster_id)
|
|
if namespace:
|
|
conditions.append(
|
|
f"({src_alias}.namespace = $namespace OR dst.namespace = $namespace)"
|
|
)
|
|
params["namespace"] = namespace
|
|
# Multi-protocol support: `protocols` (comma-separated) takes priority over `protocol` (single)
|
|
if protocols:
|
|
proto_list = [p.strip().upper() for p in protocols.split(",") if p.strip()]
|
|
if proto_list:
|
|
conditions.append(f"toUpper({rel_alias}.protocol) IN $protocols_list")
|
|
params["protocols_list"] = proto_list
|
|
elif protocol:
|
|
conditions.append(f"toUpper({rel_alias}.protocol) = toUpper($protocol)")
|
|
params["protocol"] = protocol
|
|
return " AND ".join(conditions), params
|
|
|
|
def get_l7_dependency_graph(
|
|
self,
|
|
analysis_id: str,
|
|
cluster_id: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
protocol: Optional[str] = None,
|
|
protocols: Optional[str] = None,
|
|
namespaces: Optional[str] = None,
|
|
include_metadata: bool = True,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
L7 workload dependency graph from Neo4j L7Workload / L7_COMMUNICATES_WITH.
|
|
Returns {"nodes": [...], "edges": [...]}.
|
|
"""
|
|
where_clause, params = self._l7_match_where(
|
|
analysis_id, cluster_id=cluster_id, namespace=namespace,
|
|
protocol=protocol, protocols=protocols,
|
|
)
|
|
if namespaces:
|
|
ns_list = [n.strip() for n in namespaces.split(",") if n.strip()]
|
|
if ns_list:
|
|
params["ns_list"] = ns_list
|
|
where_clause += " AND (src.namespace IN $ns_list OR dst.namespace IN $ns_list)"
|
|
meta_return = ""
|
|
if include_metadata:
|
|
meta_return = """,
|
|
src.labels AS src_labels,
|
|
src.annotations AS src_annotations,
|
|
src.owner_kind AS src_owner_kind,
|
|
dst.labels AS dst_labels,
|
|
dst.annotations AS dst_annotations,
|
|
dst.owner_kind AS dst_owner_kind"""
|
|
query = f"""
|
|
MATCH (src:L7Workload)-[r:L7_COMMUNICATES_WITH]->(dst:L7Workload)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
src.id AS src_id,
|
|
src.name AS src_name,
|
|
src.namespace AS src_namespace,
|
|
src.cluster AS src_cluster,
|
|
src.kind AS src_kind,
|
|
src.analysis_id AS src_analysis_id,
|
|
src.network_type AS src_network_type,
|
|
src.is_external AS src_is_external,
|
|
dst.id AS dst_id,
|
|
dst.name AS dst_name,
|
|
dst.namespace AS dst_namespace,
|
|
dst.cluster AS dst_cluster,
|
|
dst.kind AS dst_kind,
|
|
dst.analysis_id AS dst_analysis_id,
|
|
dst.network_type AS dst_network_type,
|
|
dst.is_external AS dst_is_external,
|
|
r.protocol AS protocol,
|
|
r.http_method AS http_method,
|
|
r.http_path AS http_path,
|
|
r.request_count AS request_count,
|
|
r.error_count AS error_count,
|
|
r.avg_latency_ms AS avg_latency_ms,
|
|
r.last_trace_id AS last_trace_id,
|
|
r.last_span_id AS last_span_id,
|
|
r.trace_count AS trace_count{meta_return}
|
|
LIMIT {settings.max_results}
|
|
"""
|
|
result = self.execute_query(query, params)
|
|
if not result.get("success"):
|
|
return {"nodes": [], "edges": [], "error": result.get("error")}
|
|
|
|
nodes_map: Dict[str, Dict[str, Any]] = {}
|
|
edges: List[Dict[str, Any]] = []
|
|
|
|
def add_node(
|
|
nid: Any,
|
|
name: Any,
|
|
ns: Any,
|
|
cluster: Any,
|
|
kind: Any,
|
|
aid: Any,
|
|
network_type: Any = None,
|
|
is_external: Any = None,
|
|
labels: Any = None,
|
|
annotations: Any = None,
|
|
owner_kind: Any = None,
|
|
) -> None:
|
|
if not nid:
|
|
return
|
|
sid = str(nid)
|
|
if sid not in nodes_map:
|
|
node: Dict[str, Any] = {
|
|
"id": sid,
|
|
"name": name or "",
|
|
"namespace": ns or "",
|
|
"cluster": cluster or "",
|
|
"kind": kind or "",
|
|
"analysis_id": str(aid) if aid is not None else "",
|
|
"network_type": str(network_type or ""),
|
|
"is_external": bool(is_external) if is_external is not None else False,
|
|
}
|
|
if include_metadata:
|
|
node["labels"] = self._parse_json_field(labels)
|
|
node["annotations"] = self._parse_json_field(annotations)
|
|
node["owner_kind"] = str(owner_kind or "")
|
|
nodes_map[sid] = node
|
|
|
|
for row in result.get("data", []):
|
|
add_node(
|
|
row.get("src_id"),
|
|
row.get("src_name"),
|
|
row.get("src_namespace"),
|
|
row.get("src_cluster"),
|
|
row.get("src_kind"),
|
|
row.get("src_analysis_id"),
|
|
network_type=row.get("src_network_type"),
|
|
is_external=row.get("src_is_external"),
|
|
labels=row.get("src_labels"),
|
|
annotations=row.get("src_annotations"),
|
|
owner_kind=row.get("src_owner_kind"),
|
|
)
|
|
add_node(
|
|
row.get("dst_id"),
|
|
row.get("dst_name"),
|
|
row.get("dst_namespace"),
|
|
row.get("dst_cluster"),
|
|
row.get("dst_kind"),
|
|
row.get("dst_analysis_id"),
|
|
network_type=row.get("dst_network_type"),
|
|
is_external=row.get("dst_is_external"),
|
|
labels=row.get("dst_labels"),
|
|
annotations=row.get("dst_annotations"),
|
|
owner_kind=row.get("dst_owner_kind"),
|
|
)
|
|
src_id = row.get("src_id")
|
|
dst_id = row.get("dst_id")
|
|
if not src_id or not dst_id:
|
|
continue
|
|
edges.append(
|
|
{
|
|
"source_id": str(src_id),
|
|
"target_id": str(dst_id),
|
|
"protocol": row.get("protocol"),
|
|
"http_method": row.get("http_method"),
|
|
"http_path": row.get("http_path"),
|
|
"request_count": row.get("request_count") or 0,
|
|
"error_count": row.get("error_count") or 0,
|
|
"avg_latency_ms": row.get("avg_latency_ms"),
|
|
"last_trace_id": str(row.get("last_trace_id") or ""),
|
|
"last_span_id": str(row.get("last_span_id") or ""),
|
|
"trace_count": int(row.get("trace_count") or 0),
|
|
}
|
|
)
|
|
|
|
# SAME_WORKLOAD bridges — surface cross-cluster equivalence to the UI
|
|
# so it can collapse "external placeholder ↔ enriched node" pairs into
|
|
# a single visual entity. Bridges are returned as a sibling list and
|
|
# not embedded into edges so the existing render path stays unchanged.
|
|
# Mirror `_l7_match_where` semantics: match either the parent
|
|
# analysis_id exactly OR any sub-analysis with the "<parent>-..." prefix.
|
|
# Without this, multi-cluster bridges (where each cluster carries a
|
|
# different sub-analysis ID) would not appear in the graph response.
|
|
same_workload_bridges: List[Dict[str, Any]] = []
|
|
try:
|
|
sw_query = """
|
|
MATCH (a:L7Workload)-[sw:SAME_WORKLOAD]->(b:L7Workload)
|
|
WHERE (a.analysis_id = $aid OR a.analysis_id STARTS WITH $aid_prefix)
|
|
AND (b.analysis_id = $aid OR b.analysis_id STARTS WITH $aid_prefix)
|
|
RETURN a.id AS a_id, b.id AS b_id,
|
|
sw.confidence AS confidence, sw.matched_by AS matched_by,
|
|
sw.last_trace_id AS last_trace_id
|
|
"""
|
|
sw_result = self.execute_query(
|
|
sw_query,
|
|
{"aid": str(analysis_id), "aid_prefix": f"{analysis_id}-"},
|
|
)
|
|
if sw_result.get("success"):
|
|
for sw_row in sw_result.get("data", []):
|
|
a_id, b_id = sw_row.get("a_id"), sw_row.get("b_id")
|
|
if not a_id or not b_id:
|
|
continue
|
|
# UI requires both endpoints to be in renderedNodeIds before
|
|
# drawing the dashed bridge edge, so bridges with only one
|
|
# endpoint visible would be dropped client-side anyway.
|
|
# Filtering server-side saves payload size on large graphs.
|
|
if str(a_id) in nodes_map and str(b_id) in nodes_map:
|
|
same_workload_bridges.append({
|
|
"a_id": str(a_id),
|
|
"b_id": str(b_id),
|
|
"confidence": str(sw_row.get("confidence") or ""),
|
|
"matched_by": str(sw_row.get("matched_by") or ""),
|
|
"last_trace_id": str(sw_row.get("last_trace_id") or ""),
|
|
})
|
|
except Exception:
|
|
logger.exception("SAME_WORKLOAD bridge query failed (non-fatal)")
|
|
|
|
return {
|
|
"nodes": list(nodes_map.values()),
|
|
"edges": edges,
|
|
"same_workload_bridges": same_workload_bridges,
|
|
"total_nodes": len(nodes_map),
|
|
"total_edges": len(edges),
|
|
}
|
|
|
|
def get_l7_communication_stats(
|
|
self,
|
|
analysis_id: str,
|
|
cluster_id: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Aggregated L7 stats: workloads, edges, requests, errors, avg latency."""
|
|
where_clause, params = self._l7_match_where(
|
|
analysis_id, cluster_id=cluster_id, namespace=None, protocol=None
|
|
)
|
|
stats_query = f"""
|
|
MATCH (src:L7Workload)-[r:L7_COMMUNICATES_WITH]->(dst:L7Workload)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
count(r) AS total_edges,
|
|
sum(coalesce(r.request_count, 0)) AS total_request_count,
|
|
sum(coalesce(r.error_count, 0)) AS total_error_count,
|
|
avg(r.avg_latency_ms) AS avg_latency_ms
|
|
"""
|
|
stats_result = self.execute_query(stats_query, params)
|
|
if not stats_result.get("success") or not stats_result.get("data"):
|
|
return {
|
|
"success": False,
|
|
"total_workloads": 0,
|
|
"total_edges": 0,
|
|
"total_request_count": 0,
|
|
"total_error_count": 0,
|
|
"avg_latency_ms": 0.0,
|
|
"error": stats_result.get("error"),
|
|
}
|
|
row0 = stats_result["data"][0]
|
|
ids_query = f"""
|
|
MATCH (src:L7Workload)-[r:L7_COMMUNICATES_WITH]->(dst:L7Workload)
|
|
WHERE {where_clause}
|
|
RETURN collect(DISTINCT src.id) + collect(DISTINCT dst.id) AS node_ids
|
|
"""
|
|
ids_result = self.execute_query(ids_query, params)
|
|
node_ids = []
|
|
if ids_result.get("success") and ids_result.get("data"):
|
|
raw = ids_result["data"][0].get("node_ids") or []
|
|
node_ids = list({str(x) for x in raw if x is not None})
|
|
avg_lat = row0.get("avg_latency_ms")
|
|
try:
|
|
avg_lat_f = float(avg_lat) if avg_lat is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
avg_lat_f = 0.0
|
|
return {
|
|
"success": True,
|
|
"total_workloads": len(node_ids),
|
|
"total_edges": int(row0.get("total_edges") or 0),
|
|
"total_request_count": int(row0.get("total_request_count") or 0),
|
|
"total_error_count": int(row0.get("total_error_count") or 0),
|
|
"avg_latency_ms": round(avg_lat_f, 4),
|
|
"analysis_id": str(analysis_id),
|
|
"cluster_id": str(cluster_id) if cluster_id else None,
|
|
}
|
|
|
|
def get_l7_dependency_summary(
|
|
self,
|
|
analysis_id: str,
|
|
cluster_id: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
include_metadata: bool = True,
|
|
annotation_key: Optional[str] = None,
|
|
annotation_value: Optional[str] = None,
|
|
label_key: Optional[str] = None,
|
|
label_value: Optional[str] = None,
|
|
owner_name: Optional[str] = None,
|
|
pod_name: Optional[str] = None,
|
|
workload_name: Optional[str] = None,
|
|
filter_noise_annotations: bool = False,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Per-workload L7 summary: inbound/outbound edge counts, requests, errors, error rate.
|
|
|
|
Audit v3 (B-16, B-19, B-22, E-13): when any of ``annotation_*``/``label_*``/
|
|
``owner_name``/``pod_name``/``workload_name`` is supplied, the response
|
|
includes both the *matched* workloads (``is_matched=True``) and their
|
|
immediate neighbours (``is_matched=False``) so the operator gets the
|
|
same dependency context the L4 path provides via ``find_pod_dependencies``.
|
|
|
|
``owner_name`` is accepted as a backend alias of ``workload_name``;
|
|
if both are passed, ``workload_name`` wins. Both perform a
|
|
case-insensitive substring match for L4 UX parity.
|
|
|
|
``filter_noise_annotations=True`` runs the shared module-level
|
|
``_filter_summary_annotations`` over each workload's annotations
|
|
in the response so infrastructure-prefixed noise is stripped.
|
|
Defaults to False (backward compat — audit G-8).
|
|
|
|
``include_metadata`` keeps its public contract: when False the
|
|
response omits ``labels``/``annotations``/``owner_kind`` on each
|
|
workload, but the engine still fetches those columns internally
|
|
so server-side filters keep working (audit B-22).
|
|
"""
|
|
# Normalise the workload-name alias up front so the rest of the
|
|
# function only deals with `effective_workload_name`.
|
|
effective_workload_name = workload_name or owner_name or None
|
|
|
|
# Detect whether any filter is active. We use this to:
|
|
# * decide if we need to widen the Cypher LIMIT (filter passes drop
|
|
# rows, so we over-fetch and let Python post-filter narrow back),
|
|
# * decide whether to emit ``is_matched`` on workloads at all
|
|
# (avoid breaking the legacy response shape when nobody is
|
|
# filtering).
|
|
filter_active = any(
|
|
v for v in (
|
|
annotation_key, label_key, label_value, effective_workload_name, pod_name,
|
|
)
|
|
)
|
|
|
|
where_clause, params = self._l7_match_where(
|
|
analysis_id, cluster_id=cluster_id, namespace=namespace, protocol=None
|
|
)
|
|
|
|
# Cypher CONTAINS prefilter — mirrors the L4 find_pod_dependencies
|
|
# pattern (L7Workload labels/annotations are persisted as JSON
|
|
# strings via graph-writer's json.dumps, so wrapping the key in
|
|
# double quotes makes the prefilter exact-enough to discard rows
|
|
# whose annotation map never references the key. Python post-filter
|
|
# below does the precise glob match.)
|
|
extra_clauses: List[str] = []
|
|
if annotation_key:
|
|
ann_key_prefix = annotation_key.split('*')[0].split('?')[0]
|
|
if ann_key_prefix:
|
|
extra_clauses.append(
|
|
"(src.annotations CONTAINS $annotation_key_search "
|
|
"OR dst.annotations CONTAINS $annotation_key_search)"
|
|
)
|
|
params["annotation_key_search"] = f'"{ann_key_prefix}'
|
|
if label_key and label_value:
|
|
extra_clauses.append(
|
|
"(src.labels CONTAINS $label_key_search "
|
|
"OR dst.labels CONTAINS $label_key_search)"
|
|
)
|
|
params["label_key_search"] = f'"{label_key}"'
|
|
elif label_key:
|
|
extra_clauses.append(
|
|
"(src.labels CONTAINS $label_key_search "
|
|
"OR dst.labels CONTAINS $label_key_search)"
|
|
)
|
|
params["label_key_search"] = f'"{label_key}"'
|
|
if effective_workload_name:
|
|
extra_clauses.append(
|
|
"(toLower(src.name) CONTAINS toLower($workload_name) "
|
|
"OR toLower(dst.name) CONTAINS toLower($workload_name))"
|
|
)
|
|
params["workload_name"] = effective_workload_name
|
|
if pod_name:
|
|
extra_clauses.append(
|
|
"(toLower(src.name) CONTAINS toLower($pod_name) "
|
|
"OR toLower(dst.name) CONTAINS toLower($pod_name))"
|
|
)
|
|
params["pod_name"] = pod_name
|
|
|
|
if extra_clauses:
|
|
where_clause = where_clause + " AND " + " AND ".join(extra_clauses)
|
|
|
|
# We always pull metadata columns from Neo4j so the post-filter has
|
|
# data to work with. The `include_metadata=False` request only
|
|
# affects what we *return* to the caller.
|
|
meta_return = """,
|
|
src.labels AS src_labels, src.annotations AS src_annotations, src.owner_kind AS src_owner_kind,
|
|
dst.labels AS dst_labels, dst.annotations AS dst_annotations, dst.owner_kind AS dst_owner_kind"""
|
|
|
|
# Widen LIMIT when filter is active — the prefilter narrows the
|
|
# Cypher result enough that 10x is still a small ceiling, but it
|
|
# gives the Python post-filter room to drop rows without producing
|
|
# a sparse final list. Capped at settings.max_results when no
|
|
# filter is active to preserve historical performance budget.
|
|
effective_limit = (settings.max_results * 10) if filter_active else settings.max_results
|
|
|
|
query = f"""
|
|
MATCH (src:L7Workload)-[r:L7_COMMUNICATES_WITH]->(dst:L7Workload)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
src.id AS src_id,
|
|
src.name AS src_name,
|
|
src.namespace AS src_namespace,
|
|
src.cluster AS src_cluster,
|
|
dst.id AS dst_id,
|
|
dst.name AS dst_name,
|
|
dst.namespace AS dst_namespace,
|
|
dst.cluster AS dst_cluster,
|
|
coalesce(r.request_count, 0) AS request_count,
|
|
coalesce(r.error_count, 0) AS error_count{meta_return}
|
|
LIMIT {effective_limit}
|
|
"""
|
|
result = self.execute_query(query, params)
|
|
if not result.get("success"):
|
|
return {
|
|
"success": False,
|
|
"workloads": [],
|
|
"error": result.get("error"),
|
|
}
|
|
|
|
by_id: Dict[str, Dict[str, Any]] = {}
|
|
|
|
def touch(
|
|
wid: Any,
|
|
name: Any,
|
|
ns: Any,
|
|
cluster: Any,
|
|
labels: Any = None,
|
|
annotations: Any = None,
|
|
owner_kind: Any = None,
|
|
) -> str:
|
|
sid = str(wid) if wid else ""
|
|
if not sid:
|
|
return ""
|
|
if sid not in by_id:
|
|
entry: Dict[str, Any] = {
|
|
"id": sid,
|
|
"name": name or "",
|
|
"namespace": ns or "",
|
|
"cluster": cluster or "",
|
|
# v2.7.0 (Audit v4): switched from per-edge counters to
|
|
# distinct-peer sets. The Neo4j MERGE key now includes
|
|
# http_method + http_path, so a single (src → dst) pair
|
|
# produces N edges (one per endpoint). Counting edges
|
|
# directly would inflate outbound_count from 1 to N,
|
|
# turning the response into a misleading "X dependencies"
|
|
# signal in the UI. By bucketing (peer_id, protocol) into
|
|
# sets we report distinct downstream / upstream services
|
|
# — which is what operators expect.
|
|
"inbound_peers": set(),
|
|
"outbound_peers": set(),
|
|
"request_count": 0,
|
|
"error_count": 0,
|
|
# Always parse metadata so server-side filters can run.
|
|
# We strip these from the response below when
|
|
# include_metadata=False.
|
|
"labels": _parse_metadata_field(labels),
|
|
"annotations": _parse_metadata_field(annotations),
|
|
"owner_kind": str(owner_kind or ""),
|
|
}
|
|
by_id[sid] = entry
|
|
return sid
|
|
|
|
for row in result.get("data", []):
|
|
rc = int(row.get("request_count") or 0)
|
|
ec = int(row.get("error_count") or 0)
|
|
proto = str(row.get("protocol") or "")
|
|
s = touch(
|
|
row.get("src_id"), row.get("src_name"), row.get("src_namespace"), row.get("src_cluster"),
|
|
row.get("src_labels"), row.get("src_annotations"), row.get("src_owner_kind"),
|
|
)
|
|
d = touch(
|
|
row.get("dst_id"), row.get("dst_name"), row.get("dst_namespace"), row.get("dst_cluster"),
|
|
row.get("dst_labels"), row.get("dst_annotations"), row.get("dst_owner_kind"),
|
|
)
|
|
if s:
|
|
if d:
|
|
by_id[s]["outbound_peers"].add((d, proto))
|
|
by_id[s]["request_count"] += rc
|
|
by_id[s]["error_count"] += ec
|
|
if d:
|
|
if s:
|
|
by_id[d]["inbound_peers"].add((s, proto))
|
|
by_id[d]["request_count"] += rc
|
|
by_id[d]["error_count"] += ec
|
|
|
|
# Python post-filter — decides which workloads count as ``matched``.
|
|
# The Cypher prefilter is intentionally permissive (CONTAINS over the
|
|
# JSON-encoded property) so the precise glob/equality check happens
|
|
# here. We never drop *rows*; we only annotate workloads with the
|
|
# ``is_matched`` flag so the operator can still see neighbours.
|
|
def _matches(entry: Dict[str, Any]) -> bool:
|
|
if not filter_active:
|
|
return True
|
|
wname = entry.get("name") or ""
|
|
ns = entry.get("namespace") or ""
|
|
ann = entry.get("annotations") or {}
|
|
lbl = entry.get("labels") or {}
|
|
if annotation_key and not _glob_match_metadata(ann, annotation_key, annotation_value):
|
|
return False
|
|
if label_key and not _glob_match_metadata(lbl, label_key, label_value):
|
|
return False
|
|
if effective_workload_name and effective_workload_name.lower() not in wname.lower():
|
|
return False
|
|
if pod_name and pod_name.lower() not in wname.lower():
|
|
return False
|
|
return True
|
|
|
|
matched_ids: set = set()
|
|
if filter_active:
|
|
for sid, entry in by_id.items():
|
|
if _matches(entry):
|
|
matched_ids.add(sid)
|
|
|
|
workloads = []
|
|
for w in by_id.values():
|
|
req = w["request_count"]
|
|
err = w["error_count"]
|
|
rate = round((err / req) * 100.0, 4) if req > 0 else 0.0
|
|
entry: Dict[str, Any] = {
|
|
"id": w["id"],
|
|
"name": w["name"],
|
|
"namespace": w["namespace"],
|
|
"cluster": w["cluster"],
|
|
# v2.7.0 (Audit v4): derive counts from distinct-peer sets so
|
|
# per-path edges don't inflate dependency counts.
|
|
"inbound_count": len(w["inbound_peers"]),
|
|
"outbound_count": len(w["outbound_peers"]),
|
|
"request_count": req,
|
|
"error_count": err,
|
|
"error_rate_percent": rate,
|
|
}
|
|
if filter_active:
|
|
entry["is_matched"] = w["id"] in matched_ids
|
|
if include_metadata:
|
|
annotations = w.get("annotations", {})
|
|
if filter_noise_annotations:
|
|
annotations = _filter_summary_annotations(annotations)
|
|
entry["labels"] = w.get("labels", {})
|
|
entry["annotations"] = annotations
|
|
entry["owner_kind"] = w.get("owner_kind", "")
|
|
workloads.append(entry)
|
|
workloads.sort(key=lambda x: (x["namespace"], x["name"]))
|
|
|
|
# When a filter is active the operator usually only cares about
|
|
# matched workloads plus their immediate neighbours. Anything else
|
|
# in the Cypher edge sweep is unrelated namespace noise — drop it
|
|
# so the workloads[] list doesn't balloon with edges that don't
|
|
# touch the matched set.
|
|
if filter_active and matched_ids:
|
|
neighbour_ids: set = set()
|
|
for row in result.get("data", []):
|
|
s_id = str(row.get("src_id") or "")
|
|
d_id = str(row.get("dst_id") or "")
|
|
if s_id in matched_ids and d_id:
|
|
neighbour_ids.add(d_id)
|
|
if d_id in matched_ids and s_id:
|
|
neighbour_ids.add(s_id)
|
|
keep_ids = matched_ids | neighbour_ids
|
|
workloads = [w for w in workloads if w["id"] in keep_ids]
|
|
|
|
response: Dict[str, Any] = {
|
|
"success": True,
|
|
"analysis_id": str(analysis_id),
|
|
"cluster_id": str(cluster_id) if cluster_id else None,
|
|
"workloads": workloads,
|
|
}
|
|
if filter_active:
|
|
response["summary"] = {
|
|
"total_matched": sum(1 for w in workloads if w.get("is_matched")),
|
|
"total_workloads": len(workloads),
|
|
}
|
|
return response
|
|
|
|
def find_l7_workload_dependencies(
|
|
self,
|
|
analysis_id: str,
|
|
cluster_id: Optional[str] = None,
|
|
workload_name: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
depth: int = 1,
|
|
label_key: Optional[str] = None,
|
|
label_value: Optional[str] = None,
|
|
annotation_key: Optional[str] = None,
|
|
annotation_value: Optional[str] = None,
|
|
include_metadata: bool = True,
|
|
workload_name_exact: bool = True,
|
|
) -> Dict[str, Any]:
|
|
"""L7 dependency tree rooted at a workload, mirroring L4's matched_services format.
|
|
|
|
When workload_name is given, returns that workload as upstream with its
|
|
downstream (outgoing) and callers (incoming) grouped by protocol.
|
|
When workload_name is omitted, returns all workloads with their edges.
|
|
|
|
``workload_name_exact`` (default True) controls the matching semantics
|
|
for ``workload_name`` so we preserve backward compatibility for
|
|
external callers that depend on exact-equality matches. The Integration
|
|
Hub frontend opts in to ``workload_name_exact=False`` (case-insensitive
|
|
substring) so the L7 tree behaves like L4 ``owner_name`` filtering.
|
|
"""
|
|
aid = str(analysis_id)
|
|
params: Dict[str, Any] = {
|
|
"analysis_id": aid,
|
|
"analysis_id_prefix": f"{aid}-",
|
|
}
|
|
|
|
rel_where = "(r.analysis_id = $analysis_id OR r.analysis_id STARTS WITH $analysis_id_prefix)"
|
|
if cluster_id:
|
|
params["cluster_id"] = str(cluster_id)
|
|
rel_where += " AND src.cluster = $cluster_id"
|
|
|
|
match_conditions = []
|
|
if workload_name:
|
|
params["workload_name"] = workload_name
|
|
if workload_name_exact:
|
|
match_conditions.append("(src.name = $workload_name OR dst.name = $workload_name)")
|
|
else:
|
|
match_conditions.append(
|
|
"(toLower(src.name) CONTAINS toLower($workload_name) "
|
|
"OR toLower(dst.name) CONTAINS toLower($workload_name))"
|
|
)
|
|
if namespace:
|
|
params["namespace"] = namespace
|
|
match_conditions.append("(src.namespace = $namespace OR dst.namespace = $namespace)")
|
|
|
|
where_full = rel_where
|
|
if match_conditions:
|
|
where_full += " AND " + " AND ".join(match_conditions)
|
|
|
|
meta_cols = ""
|
|
if include_metadata:
|
|
meta_cols = """,
|
|
src.labels AS src_labels, src.annotations AS src_annotations, src.owner_kind AS src_owner_kind,
|
|
dst.labels AS dst_labels, dst.annotations AS dst_annotations, dst.owner_kind AS dst_owner_kind"""
|
|
|
|
safe_depth = max(1, min(depth, 3))
|
|
if workload_name and safe_depth > 1:
|
|
root_ns_filter = " AND root.namespace = $namespace" if namespace else ""
|
|
if workload_name_exact:
|
|
root_name_filter = "root.name = $workload_name"
|
|
else:
|
|
root_name_filter = "toLower(root.name) CONTAINS toLower($workload_name)"
|
|
query = f"""
|
|
MATCH path = (root:L7Workload)-[rels:L7_COMMUNICATES_WITH*1..{safe_depth}]->(leaf:L7Workload)
|
|
WHERE {root_name_filter}{root_ns_filter}
|
|
AND ALL(r IN rels WHERE {rel_where.replace("src.cluster", "root.cluster")})
|
|
UNWIND relationships(path) AS r
|
|
WITH startNode(r) AS src, endNode(r) AS dst, r
|
|
RETURN DISTINCT
|
|
src.name AS src_name, src.namespace AS src_namespace, src.cluster AS src_cluster,
|
|
dst.name AS dst_name, dst.namespace AS dst_namespace, dst.cluster AS dst_cluster,
|
|
r.protocol AS protocol, r.http_method AS http_method, r.http_path AS http_path,
|
|
coalesce(r.request_count, 0) AS request_count,
|
|
coalesce(r.error_count, 0) AS error_count,
|
|
coalesce(r.avg_latency_ms, 0.0) AS avg_latency_ms{meta_cols}
|
|
LIMIT {settings.max_results}
|
|
"""
|
|
else:
|
|
query = f"""
|
|
MATCH (src:L7Workload)-[r:L7_COMMUNICATES_WITH]->(dst:L7Workload)
|
|
WHERE {where_full}
|
|
RETURN
|
|
src.name AS src_name, src.namespace AS src_namespace, src.cluster AS src_cluster,
|
|
dst.name AS dst_name, dst.namespace AS dst_namespace, dst.cluster AS dst_cluster,
|
|
r.protocol AS protocol, r.http_method AS http_method, r.http_path AS http_path,
|
|
coalesce(r.request_count, 0) AS request_count,
|
|
coalesce(r.error_count, 0) AS error_count,
|
|
coalesce(r.avg_latency_ms, 0.0) AS avg_latency_ms{meta_cols}
|
|
LIMIT {settings.max_results}
|
|
"""
|
|
result = self.execute_query(query, params)
|
|
if not result.get("success"):
|
|
return {"success": False, "error": result.get("error"), "matched_services": []}
|
|
|
|
node_key = lambda name, ns: f"{ns}/{name}"
|
|
nodes: Dict[str, Dict[str, Any]] = {}
|
|
outgoing: Dict[str, list] = defaultdict(list)
|
|
incoming: Dict[str, list] = defaultdict(list)
|
|
|
|
def ensure_node(name, ns, cluster, labels=None, annotations=None, owner_kind=None):
|
|
key = node_key(name, ns)
|
|
if key not in nodes:
|
|
nd: Dict[str, Any] = {"name": name or "", "namespace": ns or "", "cluster": cluster or ""}
|
|
if include_metadata:
|
|
nd["labels"] = self._parse_json_field(labels)
|
|
nd["annotations"] = self._parse_json_field(annotations)
|
|
nd["owner_kind"] = str(owner_kind or "")
|
|
nodes[key] = nd
|
|
return key
|
|
|
|
# Use the shared module-level glob matcher so L4 and L7 label/annotation
|
|
# filters apply identical semantics (audit B-2 / E-13: fnmatch '*', '?',
|
|
# '[seq]'; empty value or '*' means any value).
|
|
_label_match = _glob_match_metadata
|
|
|
|
for row in result.get("data", []):
|
|
sk = ensure_node(
|
|
row["src_name"], row["src_namespace"], row.get("src_cluster"),
|
|
row.get("src_labels"), row.get("src_annotations"), row.get("src_owner_kind"),
|
|
)
|
|
dk = ensure_node(
|
|
row["dst_name"], row["dst_namespace"], row.get("dst_cluster"),
|
|
row.get("dst_labels"), row.get("dst_annotations"), row.get("dst_owner_kind"),
|
|
)
|
|
edge_info = {
|
|
"name": row["dst_name"] or "",
|
|
"namespace": row["dst_namespace"] or "",
|
|
"cluster": row.get("dst_cluster") or "",
|
|
"protocol": row.get("protocol") or "",
|
|
"http_method": row.get("http_method") or "",
|
|
"http_path": row.get("http_path") or "",
|
|
"request_count": int(row.get("request_count") or 0),
|
|
"error_count": int(row.get("error_count") or 0),
|
|
"avg_latency_ms": float(row.get("avg_latency_ms") or 0.0),
|
|
}
|
|
outgoing[sk].append(edge_info)
|
|
caller_info = {
|
|
"name": row["src_name"] or "",
|
|
"namespace": row["src_namespace"] or "",
|
|
"cluster": row.get("src_cluster") or "",
|
|
"protocol": row.get("protocol") or "",
|
|
"http_method": row.get("http_method") or "",
|
|
"http_path": row.get("http_path") or "",
|
|
"request_count": int(row.get("request_count") or 0),
|
|
"error_count": int(row.get("error_count") or 0),
|
|
"avg_latency_ms": float(row.get("avg_latency_ms") or 0.0),
|
|
}
|
|
incoming[dk].append(caller_info)
|
|
|
|
def group_by_protocol(edges: list) -> Dict[str, list]:
|
|
groups: Dict[str, list] = defaultdict(list)
|
|
for e in edges:
|
|
cat = e.get("protocol") or "unknown"
|
|
groups[cat].append(e)
|
|
return dict(groups)
|
|
|
|
if workload_name:
|
|
if workload_name_exact:
|
|
root_keys = [k for k, n in nodes.items()
|
|
if n["name"] == workload_name
|
|
and (not namespace or n["namespace"] == namespace)]
|
|
else:
|
|
needle = workload_name.lower()
|
|
root_keys = [k for k, n in nodes.items()
|
|
if needle in (n["name"] or "").lower()
|
|
and (not namespace or n["namespace"] == namespace)]
|
|
else:
|
|
root_keys = sorted(nodes.keys())
|
|
|
|
matched_services = []
|
|
for rk in root_keys:
|
|
n = nodes[rk]
|
|
node_labels = n.get("labels", {}) if include_metadata else {}
|
|
node_annots = n.get("annotations", {}) if include_metadata else {}
|
|
if not _label_match(node_labels, label_key, label_value):
|
|
continue
|
|
if not _label_match(node_annots, annotation_key, annotation_value):
|
|
continue
|
|
ds_edges = outgoing.get(rk, [])
|
|
cl_edges = incoming.get(rk, [])
|
|
svc: Dict[str, Any] = {
|
|
"name": n["name"],
|
|
"namespace": n["namespace"],
|
|
"cluster": n["cluster"],
|
|
"downstream": {
|
|
"total": len(ds_edges),
|
|
"by_protocol": group_by_protocol(ds_edges),
|
|
},
|
|
"callers": {
|
|
"total": len(cl_edges),
|
|
"by_protocol": group_by_protocol(cl_edges),
|
|
},
|
|
}
|
|
if include_metadata:
|
|
svc["labels"] = node_labels
|
|
svc["annotations"] = node_annots
|
|
svc["owner_kind"] = n.get("owner_kind", "")
|
|
matched_services.append(svc)
|
|
|
|
total_ds = sum(s["downstream"]["total"] for s in matched_services)
|
|
total_cl = sum(s["callers"]["total"] for s in matched_services)
|
|
|
|
return {
|
|
"success": True,
|
|
"analysis_id": aid,
|
|
"cluster_id": str(cluster_id) if cluster_id else None,
|
|
"multi_service": len(matched_services) > 1,
|
|
"summary": {
|
|
"total_matched": len(matched_services),
|
|
"total_downstream": total_ds,
|
|
"total_callers": total_cl,
|
|
"total_workloads": len(nodes),
|
|
},
|
|
"matched_services": matched_services,
|
|
}
|
|
|
|
def get_l7_communications(
|
|
self,
|
|
analysis_id: str,
|
|
cluster_id: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
protocol: Optional[str] = None,
|
|
limit: int = 100,
|
|
) -> Dict[str, Any]:
|
|
"""Flat L7 communication records (Neo4j shape compatible with /communications)."""
|
|
where_clause, params = self._l7_match_where(
|
|
analysis_id,
|
|
cluster_id=cluster_id,
|
|
namespace=namespace,
|
|
protocol=protocol,
|
|
)
|
|
params["limit"] = min(limit, settings.max_results)
|
|
query = f"""
|
|
MATCH (src:L7Workload)-[r:L7_COMMUNICATES_WITH]->(dst:L7Workload)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
src.id AS source_id,
|
|
src.name AS source_name,
|
|
src.namespace AS source_namespace,
|
|
src.cluster AS source_cluster,
|
|
src.kind AS source_kind,
|
|
dst.id AS destination_id,
|
|
dst.name AS destination_name,
|
|
dst.namespace AS destination_namespace,
|
|
dst.cluster AS destination_cluster,
|
|
dst.kind AS destination_kind,
|
|
r.protocol AS protocol,
|
|
r.http_method AS http_method,
|
|
r.http_path AS http_path,
|
|
r.request_count AS request_count,
|
|
r.error_count AS error_count,
|
|
r.avg_latency_ms AS avg_latency_ms,
|
|
r.analysis_id AS analysis_id
|
|
ORDER BY coalesce(r.request_count, 0) DESC
|
|
LIMIT $limit
|
|
"""
|
|
return self.execute_query(query, params)
|
|
|
|
def get_l7_error_stats(
|
|
self,
|
|
analysis_id: str,
|
|
cluster_id: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""L7 error aggregation by protocol and totals."""
|
|
where_clause, params = self._l7_match_where(
|
|
analysis_id,
|
|
cluster_id=cluster_id,
|
|
namespace=namespace,
|
|
protocol=None,
|
|
)
|
|
query = f"""
|
|
MATCH (src:L7Workload)-[r:L7_COMMUNICATES_WITH]->(dst:L7Workload)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
r.protocol AS protocol,
|
|
sum(coalesce(r.error_count, 0)) AS error_count,
|
|
sum(coalesce(r.request_count, 0)) AS request_count
|
|
"""
|
|
agg = self.execute_query(query, params)
|
|
by_protocol: Dict[str, Dict[str, int]] = {}
|
|
total_err = 0
|
|
total_req = 0
|
|
if not agg.get("success"):
|
|
return {
|
|
"success": False,
|
|
"analysis_id": str(analysis_id),
|
|
"cluster_id": str(cluster_id) if cluster_id else None,
|
|
"namespace": namespace,
|
|
"error": agg.get("error", "Neo4j query failed"),
|
|
"total_errors": 0,
|
|
"total_requests": 0,
|
|
"error_rate_percent": 0.0,
|
|
"by_protocol": {},
|
|
}
|
|
for row in agg.get("data", []):
|
|
proto = row.get("protocol") or "UNKNOWN"
|
|
e = int(row.get("error_count") or 0)
|
|
q = int(row.get("request_count") or 0)
|
|
by_protocol[proto] = {"error_count": e, "request_count": q}
|
|
total_err += e
|
|
total_req += q
|
|
rate = round((total_err / total_req) * 100.0, 4) if total_req > 0 else 0.0
|
|
return {
|
|
"success": True,
|
|
"analysis_id": str(analysis_id),
|
|
"cluster_id": str(cluster_id) if cluster_id else None,
|
|
"namespace": namespace,
|
|
"total_errors": total_err,
|
|
"total_requests": total_req,
|
|
"error_rate_percent": rate,
|
|
"by_protocol": by_protocol,
|
|
}
|
|
|
|
def health_check(self) -> Dict[str, Any]:
|
|
"""Check Neo4j connection health"""
|
|
try:
|
|
if not self.driver:
|
|
return {"healthy": False, "error": "No driver"}
|
|
|
|
self.driver.verify_connectivity()
|
|
|
|
# Execute simple query
|
|
result = self.execute_query("RETURN 1 AS test")
|
|
|
|
if result.get("success"):
|
|
return {
|
|
"healthy": True,
|
|
"database": self.database,
|
|
"uri": settings.neo4j_bolt_uri
|
|
}
|
|
else:
|
|
return {
|
|
"healthy": False,
|
|
"error": result.get("error")
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"healthy": False,
|
|
"error": str(e)
|
|
}
|
|
|
|
def find_pod_dependencies(
|
|
self,
|
|
analysis_id: Optional[str] = None,
|
|
analysis_ids: Optional[List[str]] = None,
|
|
cluster_id: Optional[str] = None,
|
|
pod_name: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
owner_name: Optional[str] = None,
|
|
label_key: Optional[str] = None,
|
|
label_value: Optional[str] = None,
|
|
annotation_key: Optional[str] = None,
|
|
annotation_value: Optional[str] = None,
|
|
ip: Optional[str] = None,
|
|
depth: int = 1,
|
|
include_communication_details: bool = True,
|
|
# Plan v3 Akış D m.8 — Discovery mode.
|
|
#
|
|
# When the operator hasn't picked a service identification method
|
|
# (no annotation/label/owner/pod_name/ip), they may still want to
|
|
# see the dependency graph for the analysis scope as a whole.
|
|
# The previous behaviour was to hard-fail with "At least one
|
|
# search parameter required". `match_all=True` opts into the
|
|
# broader query but keeps the result bounded:
|
|
#
|
|
# - depth is capped at MAX_DISCOVERY_DEPTH (=2): preventing a
|
|
# `depth=5` discovery from producing a graph that takes
|
|
# 30s+ to render.
|
|
# - the workload `LIMIT` (200) becomes the *seed* limit and
|
|
# the operator must pass `cluster_id` AND/OR `namespace` so
|
|
# a single analysis owner can't accidentally enumerate
|
|
# every workload in a shared cluster ("tenant guard").
|
|
#
|
|
# When `match_all` is False the previous semantics apply
|
|
# verbatim — backward compatible.
|
|
match_all: bool = False,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Find a pod by any metadata and return its upstream/downstream dependencies.
|
|
|
|
The matched pod is the "upstream" (source). All pods it communicates with
|
|
are "downstream" (targets). Pods that communicate TO the matched pod are
|
|
also returned as callers (reverse upstream).
|
|
|
|
Any combination of search parameters can be used. At least one is required
|
|
unless `match_all=True` (discovery mode, see kwargs).
|
|
|
|
Args:
|
|
analysis_id: Single analysis ID for scope (backward compat)
|
|
analysis_ids: Multiple analysis IDs for scope (takes precedence over analysis_id)
|
|
cluster_id: Cluster ID for scope
|
|
pod_name: Pod/workload name to search
|
|
namespace: Namespace to narrow search
|
|
owner_name: Deployment/StatefulSet/DaemonSet name to search
|
|
label_key/label_value: Label key=value to match
|
|
annotation_key/annotation_value: Annotation key=value to match
|
|
ip: Pod IP to search
|
|
depth: Traversal depth for dependencies (default 1)
|
|
|
|
Returns:
|
|
Dict with upstream pod info and downstream dependencies
|
|
"""
|
|
# Build match conditions to find the target pod
|
|
match_conditions = []
|
|
params = {}
|
|
|
|
# Consolidate analysis_ids (plural takes precedence)
|
|
effective_ids = None
|
|
if analysis_ids:
|
|
effective_ids = [str(a) for a in analysis_ids]
|
|
elif analysis_id:
|
|
effective_ids = [str(analysis_id)]
|
|
|
|
if cluster_id:
|
|
params["cluster_id"] = str(cluster_id)
|
|
|
|
if pod_name:
|
|
match_conditions.append("toLower(w.name) CONTAINS toLower($pod_name)")
|
|
params["pod_name"] = pod_name
|
|
|
|
if namespace:
|
|
match_conditions.append("w.namespace = $namespace")
|
|
params["namespace"] = namespace
|
|
|
|
if owner_name:
|
|
match_conditions.append("toLower(w.owner_name) CONTAINS toLower($owner_name)")
|
|
params["owner_name"] = owner_name
|
|
|
|
if ip:
|
|
match_conditions.append("w.ip = $ip")
|
|
params["ip"] = ip
|
|
|
|
if annotation_key:
|
|
if '*' in annotation_key or '?' in annotation_key:
|
|
ann_key_prefix = annotation_key.split('*')[0].split('?')[0]
|
|
if ann_key_prefix:
|
|
match_conditions.append(
|
|
"w.annotations CONTAINS $annotation_key_search"
|
|
)
|
|
params["annotation_key_search"] = f'"{ann_key_prefix}'
|
|
else:
|
|
match_conditions.append(
|
|
"w.annotations CONTAINS $annotation_key_search"
|
|
)
|
|
params["annotation_key_search"] = f'"{annotation_key}"'
|
|
|
|
if label_key and label_value:
|
|
match_conditions.append(
|
|
"w.labels CONTAINS $label_search"
|
|
)
|
|
params["label_search"] = f'"{label_key}"'
|
|
params["label_value"] = label_value
|
|
elif label_key:
|
|
match_conditions.append(
|
|
"w.labels CONTAINS $label_key_search"
|
|
)
|
|
params["label_key_search"] = f'"{label_key}"'
|
|
|
|
# Plan v3 Akış D m.8 — discovery mode (`match_all=True`) lets
|
|
# the operator see "all workloads in scope" without picking a
|
|
# service identification method. We still require a tenant
|
|
# guard (cluster_id OR namespace) AND an analysis scope so a
|
|
# single discovery query can't enumerate every workload in a
|
|
# shared multi-tenant cluster. depth is also capped to keep
|
|
# graph size predictable.
|
|
MAX_DISCOVERY_DEPTH = 2
|
|
DISCOVERY_SEED_LIMIT = 200
|
|
if not match_conditions:
|
|
if not match_all:
|
|
return {"success": False, "error": "At least one search parameter required (pod_name, namespace, owner_name, ip, annotation_key, label_key) or pass match_all=true for discovery mode", "count": 0, "results": []}
|
|
# Tenant guard: discovery mode without ANY narrowing scope
|
|
# would enumerate every workload in every analysis the
|
|
# caller has access to. Force at least one of:
|
|
# - cluster_id (single-cluster discovery)
|
|
# - namespace (single-namespace discovery)
|
|
# A bare analysis_id is NOT enough because a single analysis
|
|
# may span thousands of workloads. The operator can still
|
|
# combine namespace + cluster + analysis if they want even
|
|
# tighter scoping.
|
|
if not cluster_id and not namespace:
|
|
return {
|
|
"success": False,
|
|
"error": "Discovery mode requires either cluster_id or namespace to bound the result set.",
|
|
"count": 0,
|
|
"results": [],
|
|
}
|
|
if depth > MAX_DISCOVERY_DEPTH:
|
|
# Silently cap rather than fail: the operator's
|
|
# `depth=5` choice still gets them a useful result
|
|
# (depth-2), and the response payload echoes back
|
|
# `effective_depth` (added below) so the UI can show
|
|
# "depth capped at 2 for discovery mode".
|
|
depth = MAX_DISCOVERY_DEPTH
|
|
|
|
# Add analysis scope filter
|
|
if effective_ids:
|
|
params["analysis_ids"] = effective_ids
|
|
params["analysis_id_prefixes"] = [f"{a}-" for a in effective_ids]
|
|
match_conditions.append(
|
|
"(w.analysis_id IN $analysis_ids OR "
|
|
"ANY(prefix IN $analysis_id_prefixes WHERE w.analysis_id STARTS WITH prefix))"
|
|
)
|
|
if cluster_id:
|
|
match_conditions.append("w.cluster_id = $cluster_id")
|
|
|
|
where_clause = " AND ".join(match_conditions)
|
|
|
|
# Step 1: Find the upstream pod(s) matching criteria
|
|
find_query = f"""
|
|
MATCH (w:Workload)
|
|
WHERE {where_clause}
|
|
RETURN
|
|
w.id AS id,
|
|
w.name AS name,
|
|
w.namespace AS namespace,
|
|
w.cluster_id AS cluster_id,
|
|
w.ip AS ip,
|
|
w.labels AS labels,
|
|
w.annotations AS annotations,
|
|
w.owner_kind AS owner_kind,
|
|
w.owner_name AS owner_name,
|
|
w.phase AS phase,
|
|
w.image AS image,
|
|
w.container AS container,
|
|
w.service_account AS service_account,
|
|
w.host_ip AS host_ip,
|
|
w.pod_uid AS pod_uid,
|
|
w.node AS node
|
|
LIMIT 200
|
|
"""
|
|
|
|
find_result = self.execute_query(find_query, params)
|
|
|
|
if not find_result.get("success") or not find_result.get("data"):
|
|
return {
|
|
"success": False,
|
|
"error": "No pod found matching the given criteria",
|
|
"search_params": {
|
|
k: v for k, v in {
|
|
"pod_name": pod_name, "namespace": namespace,
|
|
"owner_name": owner_name,
|
|
"annotation_key": annotation_key, "annotation_value": annotation_value,
|
|
"label_key": label_key, "label_value": label_value,
|
|
"ip": ip
|
|
}.items() if v
|
|
}
|
|
}
|
|
|
|
# Post-filter for annotation_key/value (supports * glob pattern)
|
|
matched_pods = find_result["data"]
|
|
if annotation_key:
|
|
from fnmatch import fnmatch
|
|
ann_key_has_glob = '*' in annotation_key or '?' in annotation_key
|
|
ann_val_any = not annotation_value or annotation_value == '*'
|
|
ann_val_has_glob = not ann_val_any and ('*' in annotation_value or '?' in annotation_value)
|
|
filtered = []
|
|
for pod in matched_pods:
|
|
ann_raw = pod.get("annotations", "{}")
|
|
if isinstance(ann_raw, str):
|
|
try:
|
|
ann = json.loads(ann_raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
ann = {}
|
|
else:
|
|
ann = ann_raw or {}
|
|
|
|
hit_keys = [k for k in ann if fnmatch(k, annotation_key)] if ann_key_has_glob else ([annotation_key] if annotation_key in ann else [])
|
|
if not hit_keys:
|
|
continue
|
|
if ann_val_any:
|
|
filtered.append(pod)
|
|
continue
|
|
for k in hit_keys:
|
|
v = str(ann[k])
|
|
if ann_val_has_glob:
|
|
if fnmatch(v, annotation_value):
|
|
filtered.append(pod)
|
|
break
|
|
else:
|
|
if v == annotation_value:
|
|
filtered.append(pod)
|
|
break
|
|
matched_pods = filtered
|
|
|
|
if label_key and label_value:
|
|
filtered = []
|
|
for pod in matched_pods:
|
|
lbl_raw = pod.get("labels", "{}")
|
|
if isinstance(lbl_raw, str):
|
|
try:
|
|
lbl = json.loads(lbl_raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
lbl = {}
|
|
else:
|
|
lbl = lbl_raw or {}
|
|
if lbl.get(label_key) == label_value:
|
|
filtered.append(pod)
|
|
matched_pods = filtered
|
|
|
|
if not matched_pods:
|
|
return {
|
|
"success": False,
|
|
"error": "No pod found matching the given criteria after filtering",
|
|
"search_params": {
|
|
k: v for k, v in {
|
|
"pod_name": pod_name, "namespace": namespace,
|
|
"owner_name": owner_name,
|
|
"annotation_key": annotation_key, "annotation_value": annotation_value,
|
|
"label_key": label_key, "label_value": label_value,
|
|
"ip": ip
|
|
}.items() if v
|
|
}
|
|
}
|
|
|
|
results = []
|
|
|
|
for upstream_pod in matched_pods:
|
|
pod_id = upstream_pod["id"]
|
|
|
|
# Parse JSON fields
|
|
for field in ["labels", "annotations"]:
|
|
raw = upstream_pod.get(field, "{}")
|
|
if isinstance(raw, str):
|
|
try:
|
|
upstream_pod[field] = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
upstream_pod[field] = {}
|
|
elif not isinstance(raw, dict):
|
|
upstream_pod[field] = {}
|
|
|
|
# Step 2: Get downstream (pods this upstream connects TO)
|
|
depth_val = max(1, min(depth, 5))
|
|
downstream_query = f"""
|
|
MATCH path = (src:Workload {{id: $pod_id}})-[:COMMUNICATES_WITH*1..{depth_val}]->(dst)
|
|
WHERE dst.id <> $pod_id
|
|
WITH dst, path, length(path) as hops
|
|
ORDER BY hops ASC
|
|
WITH dst, collect(path)[0] as sp
|
|
WITH dst, length(sp) as hop_count, relationships(sp) as rels
|
|
WITH dst, hop_count, rels[size(rels)-1] as r
|
|
RETURN
|
|
dst.id AS id,
|
|
dst.name AS name,
|
|
dst.namespace AS namespace,
|
|
dst.cluster_id AS cluster_id,
|
|
dst.ip AS ip,
|
|
dst.labels AS labels,
|
|
dst.annotations AS annotations,
|
|
dst.owner_kind AS owner_kind,
|
|
dst.owner_name AS owner_name,
|
|
dst.phase AS phase,
|
|
dst.image AS image,
|
|
dst.container AS container,
|
|
dst.service_account AS service_account,
|
|
dst.host_ip AS host_ip,
|
|
dst.pod_uid AS pod_uid,
|
|
dst.node AS node,
|
|
hop_count,
|
|
r.protocol AS protocol,
|
|
r.port AS port,
|
|
r.destination_port AS destination_port,
|
|
r.app_protocol AS app_protocol,
|
|
r.request_count AS request_count,
|
|
r.bytes_transferred AS bytes_transferred,
|
|
r.error_count AS error_count,
|
|
r.retransmit_count AS retransmit_count,
|
|
r.avg_latency_ms AS avg_latency_ms,
|
|
r.last_seen AS last_seen
|
|
ORDER BY hop_count, dst.name
|
|
LIMIT 200
|
|
"""
|
|
|
|
downstream_result = self.execute_query(downstream_query, {"pod_id": pod_id})
|
|
downstream_pods = []
|
|
|
|
if downstream_result.get("success"):
|
|
for d in downstream_result.get("data", []):
|
|
for field in ["labels", "annotations"]:
|
|
raw = d.get(field, "{}")
|
|
if isinstance(raw, str):
|
|
try:
|
|
d[field] = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
d[field] = {}
|
|
elif not isinstance(raw, dict):
|
|
d[field] = {}
|
|
|
|
port = d.get("destination_port") or d.get("port")
|
|
request_count = d.get("request_count") or 0
|
|
error_count = d.get("error_count") or 0
|
|
retransmit_count = d.get("retransmit_count") or 0
|
|
avg_latency = d.get("avg_latency_ms") or 0
|
|
|
|
dep_entry = {
|
|
"pod_name": d.get("name"),
|
|
"namespace": d.get("namespace"),
|
|
"cluster_id": d.get("cluster_id"),
|
|
"ip": d.get("ip"),
|
|
"labels": d.get("labels", {}),
|
|
"annotations": d.get("annotations", {}),
|
|
"owner_kind": d.get("owner_kind"),
|
|
"owner_name": d.get("owner_name"),
|
|
"phase": d.get("phase"),
|
|
"image": d.get("image"),
|
|
"container": d.get("container"),
|
|
"service_account": d.get("service_account"),
|
|
"host_ip": d.get("host_ip"),
|
|
"node": d.get("node"),
|
|
"hop_count": d.get("hop_count", 1),
|
|
}
|
|
if include_communication_details:
|
|
dep_entry["communication"] = self._build_communication_contract(
|
|
d.get("protocol"), d.get("app_protocol"), port,
|
|
request_count, d.get("bytes_transferred"),
|
|
error_count, retransmit_count, avg_latency,
|
|
d.get("last_seen"),
|
|
workload_name=d.get("name", "")
|
|
)
|
|
dep_entry["health"] = self._calculate_dependency_health(
|
|
request_count, error_count, retransmit_count, avg_latency
|
|
)
|
|
downstream_pods.append(dep_entry)
|
|
|
|
# Step 3: Get callers (pods that connect TO this upstream pod - reverse direction)
|
|
callers_query = f"""
|
|
MATCH path = (caller)-[:COMMUNICATES_WITH*1..{depth_val}]->(target:Workload {{id: $pod_id}})
|
|
WHERE caller.id <> $pod_id
|
|
WITH caller, path, length(path) as hops
|
|
ORDER BY hops ASC
|
|
WITH caller, collect(path)[0] as sp
|
|
WITH caller, length(sp) as hop_count, relationships(sp) as rels
|
|
WITH caller, hop_count, rels[size(rels)-1] as r
|
|
RETURN
|
|
caller.id AS id,
|
|
caller.name AS name,
|
|
caller.namespace AS namespace,
|
|
caller.cluster_id AS cluster_id,
|
|
caller.ip AS ip,
|
|
caller.labels AS labels,
|
|
caller.annotations AS annotations,
|
|
caller.owner_kind AS owner_kind,
|
|
caller.owner_name AS owner_name,
|
|
caller.phase AS phase,
|
|
caller.image AS image,
|
|
caller.container AS container,
|
|
caller.service_account AS service_account,
|
|
caller.host_ip AS host_ip,
|
|
caller.pod_uid AS pod_uid,
|
|
caller.node AS node,
|
|
hop_count,
|
|
r.protocol AS protocol,
|
|
r.port AS port,
|
|
r.destination_port AS destination_port,
|
|
r.app_protocol AS app_protocol,
|
|
r.request_count AS request_count,
|
|
r.bytes_transferred AS bytes_transferred,
|
|
r.error_count AS error_count,
|
|
r.retransmit_count AS retransmit_count,
|
|
r.avg_latency_ms AS avg_latency_ms,
|
|
r.last_seen AS last_seen
|
|
ORDER BY hop_count, caller.name
|
|
LIMIT 200
|
|
"""
|
|
|
|
callers_result = self.execute_query(callers_query, {"pod_id": pod_id})
|
|
caller_pods = []
|
|
|
|
if callers_result.get("success"):
|
|
for c in callers_result.get("data", []):
|
|
for field in ["labels", "annotations"]:
|
|
raw = c.get(field, "{}")
|
|
if isinstance(raw, str):
|
|
try:
|
|
c[field] = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
c[field] = {}
|
|
elif not isinstance(raw, dict):
|
|
c[field] = {}
|
|
|
|
port = c.get("destination_port") or c.get("port")
|
|
request_count = c.get("request_count") or 0
|
|
error_count = c.get("error_count") or 0
|
|
retransmit_count = c.get("retransmit_count") or 0
|
|
avg_latency = c.get("avg_latency_ms") or 0
|
|
|
|
caller_entry = {
|
|
"pod_name": c.get("name"),
|
|
"namespace": c.get("namespace"),
|
|
"cluster_id": c.get("cluster_id"),
|
|
"ip": c.get("ip"),
|
|
"labels": c.get("labels", {}),
|
|
"annotations": c.get("annotations", {}),
|
|
"owner_kind": c.get("owner_kind"),
|
|
"owner_name": c.get("owner_name"),
|
|
"phase": c.get("phase"),
|
|
"image": c.get("image"),
|
|
"container": c.get("container"),
|
|
"service_account": c.get("service_account"),
|
|
"host_ip": c.get("host_ip"),
|
|
"node": c.get("node"),
|
|
"hop_count": c.get("hop_count", 1),
|
|
}
|
|
if include_communication_details:
|
|
caller_entry["communication"] = self._build_communication_contract(
|
|
c.get("protocol"), c.get("app_protocol"), port,
|
|
request_count, c.get("bytes_transferred"),
|
|
error_count, retransmit_count, avg_latency,
|
|
c.get("last_seen"),
|
|
workload_name=c.get("name", "")
|
|
)
|
|
caller_entry["health"] = self._calculate_dependency_health(
|
|
request_count, error_count, retransmit_count, avg_latency
|
|
)
|
|
caller_pods.append(caller_entry)
|
|
|
|
results.append({
|
|
"upstream": {
|
|
"pod_name": upstream_pod.get("name"),
|
|
"namespace": upstream_pod.get("namespace"),
|
|
"cluster_id": upstream_pod.get("cluster_id"),
|
|
"ip": upstream_pod.get("ip"),
|
|
"labels": upstream_pod.get("labels", {}),
|
|
"annotations": upstream_pod.get("annotations", {}),
|
|
"owner_kind": upstream_pod.get("owner_kind"),
|
|
"owner_name": upstream_pod.get("owner_name"),
|
|
"phase": upstream_pod.get("phase"),
|
|
"image": upstream_pod.get("image"),
|
|
"container": upstream_pod.get("container"),
|
|
"service_account": upstream_pod.get("service_account"),
|
|
"host_ip": upstream_pod.get("host_ip"),
|
|
"node": upstream_pod.get("node")
|
|
},
|
|
"downstream": downstream_pods,
|
|
"callers": caller_pods
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"count": len(results),
|
|
"results": results
|
|
}
|
|
|
|
def batch_find_dependencies(
|
|
self,
|
|
analysis_id: Optional[str] = None,
|
|
cluster_id: Optional[str] = None,
|
|
services: List[Dict[str, Any]] = None,
|
|
depth: int = 1,
|
|
include_communication_details: bool = True,
|
|
) -> Dict[str, Any]:
|
|
"""Batch find dependencies for multiple services in one call."""
|
|
if not services:
|
|
return {"error": "services list is required"}
|
|
|
|
all_results = []
|
|
all_downstream_ids: List[set] = []
|
|
|
|
for svc in services:
|
|
result = self.find_pod_dependencies(
|
|
analysis_id=analysis_id,
|
|
cluster_id=cluster_id,
|
|
pod_name=svc.get("pod_name"),
|
|
namespace=svc.get("namespace"),
|
|
owner_name=svc.get("owner_name"),
|
|
label_key=svc.get("label_key"),
|
|
label_value=svc.get("label_value"),
|
|
annotation_key=svc.get("annotation_key"),
|
|
annotation_value=svc.get("annotation_value"),
|
|
ip=svc.get("ip"),
|
|
depth=depth,
|
|
include_communication_details=include_communication_details,
|
|
)
|
|
all_results.append(result)
|
|
|
|
ids = set()
|
|
if result.get("success"):
|
|
for r in result.get("results", []):
|
|
for d in r.get("downstream", []):
|
|
name = d.get("pod_name") or d.get("owner_name") or ""
|
|
ns = d.get("namespace", "")
|
|
ids.add(f"{ns}/{name}")
|
|
all_downstream_ids.append(ids)
|
|
|
|
shared = set()
|
|
if len(all_downstream_ids) >= 2:
|
|
shared = all_downstream_ids[0]
|
|
for s in all_downstream_ids[1:]:
|
|
shared = shared & s
|
|
|
|
return {
|
|
"success": True,
|
|
"service_count": len(services),
|
|
"results": all_results,
|
|
"shared_dependencies": sorted(shared),
|
|
}
|
|
|
|
def format_dependency_summary(
|
|
self,
|
|
stream_result: Dict[str, Any],
|
|
analysis_ids: List[str],
|
|
) -> Dict[str, Any]:
|
|
"""Transform find_pod_dependencies output into a compact, AI-agent-friendly
|
|
grouped format. Dependencies are grouped by service_category with only the
|
|
fields relevant for cross-project impact analysis.
|
|
|
|
When multiple pods match (e.g. namespace-wide query), aggregates ALL
|
|
downstream/caller entries (deduplicated) and exposes matched_services.
|
|
Replica pods belonging to the same Deployment/StatefulSet are collapsed
|
|
into a single logical workload entry.
|
|
"""
|
|
try:
|
|
int_ids = [int(a) for a in analysis_ids]
|
|
except (ValueError, TypeError):
|
|
int_ids = analysis_ids
|
|
|
|
if not stream_result.get("success") or not stream_result.get("results"):
|
|
return {
|
|
"success": False,
|
|
"analysis_ids": int_ids,
|
|
"error": stream_result.get("error", "No results"),
|
|
}
|
|
|
|
results = stream_result["results"]
|
|
|
|
_safe_labels = self._safe_labels
|
|
_strip_template_hash = self._strip_template_hash
|
|
|
|
def _workload_name(entry: dict) -> str:
|
|
"""Resolve the logical workload name from the richest source available."""
|
|
labels = _safe_labels(entry)
|
|
name = labels.get("app.kubernetes.io/name") or labels.get("app")
|
|
if name:
|
|
return name
|
|
|
|
pth = labels.get("pod-template-hash", "")
|
|
owner = entry.get("owner_name") or ""
|
|
if owner:
|
|
return _strip_template_hash(owner, pth)
|
|
|
|
return _strip_template_hash(entry.get("pod_name", ""), pth)
|
|
|
|
def _workload_key(entry: dict) -> str:
|
|
"""Determine a stable identity key that collapses replica pods into
|
|
their owning Deployment/StatefulSet.
|
|
|
|
Resolution order:
|
|
1. namespace + app.kubernetes.io/name or app label (most reliable)
|
|
2. namespace + owner_name (stripped of template hash if present)
|
|
3. namespace + pod name (stripped of template hash if present)
|
|
"""
|
|
ns = entry.get("namespace", "")
|
|
labels = _safe_labels(entry)
|
|
|
|
name = labels.get("app.kubernetes.io/name") or labels.get("app")
|
|
if name:
|
|
return f"{ns}/{name}"
|
|
|
|
pth = labels.get("pod-template-hash", "")
|
|
owner = entry.get("owner_name") or ""
|
|
if owner:
|
|
return f"{ns}/{_strip_template_hash(owner, pth)}"
|
|
|
|
pod = entry.get("pod_name") or ""
|
|
return f"{ns}/{_strip_template_hash(pod, pth)}"
|
|
|
|
def _is_noise_entry(entry: dict) -> bool:
|
|
"""Filter out noise: reverse DNS, bare IPs with no metadata, 0.0.0.0."""
|
|
name = entry.get("pod_name") or entry.get("owner_name") or ""
|
|
if name.endswith(".in-addr.arpa.") or name.endswith(".in-addr.arpa"):
|
|
return True
|
|
if name in ("0.0.0.0", "0.0.0.0:0"):
|
|
return True
|
|
comm = entry.get("communication") or {}
|
|
port = comm.get("port") or 0
|
|
ns = entry.get("namespace", "")
|
|
ann = entry.get("annotations") or {}
|
|
lbl = entry.get("labels") or {}
|
|
owner_kind = entry.get("owner_kind") or ""
|
|
if port == 0 and not ann and not lbl and not owner_kind and ns in ("external", "cluster-network", ""):
|
|
return True
|
|
if ns == "sdn-infrastructure" and not ann and not lbl and not owner_kind:
|
|
return True
|
|
return False
|
|
|
|
_KIND_ALIASES = {"ReplicaSet": "Deployment"}
|
|
|
|
# _NOISE_ANNOTATION_PREFIXES and _filter_summary_annotations have moved
|
|
# to module scope so they can be re-used by the L7 dependency_summary
|
|
# filter path (audit v3 / E-10).
|
|
|
|
def _resolve_kind(raw: str, labels: dict = None) -> str:
|
|
if raw in _KIND_ALIASES:
|
|
return _KIND_ALIASES[raw]
|
|
if raw in ("Unknown", "") and labels:
|
|
if labels.get("pod-template-hash"):
|
|
return "Deployment"
|
|
if labels.get("controller-revision-hash"):
|
|
if labels.get("statefulset.kubernetes.io/pod-name"):
|
|
return "StatefulSet"
|
|
return "DaemonSet"
|
|
return raw
|
|
|
|
def _compact_service(entry: dict, direction: str = "downstream") -> dict:
|
|
comm = entry.get("communication") or {}
|
|
svc_type = comm.get("service_type", "unknown")
|
|
svc_cat = comm.get("service_category", "")
|
|
is_crit = comm.get("is_critical", False)
|
|
if not svc_cat:
|
|
svc_cat = self.classify_service_category(svc_type, entry.get("pod_name", ""))
|
|
if not is_crit:
|
|
is_crit = self.is_critical_service(svc_type, entry.get("pod_name", ""))
|
|
labels = _safe_labels(entry)
|
|
result = {
|
|
"name": _workload_name(entry),
|
|
"namespace": entry.get("namespace", ""),
|
|
"kind": _resolve_kind(entry.get("owner_kind") or "", labels),
|
|
"annotations": _filter_summary_annotations(entry.get("annotations", {})),
|
|
"labels": labels,
|
|
"is_critical": is_crit,
|
|
"service_type": svc_type,
|
|
"service_category": svc_cat,
|
|
"port": comm.get("port"),
|
|
}
|
|
hop = entry.get("hop_count", 1)
|
|
if hop > 1:
|
|
result["hop_count"] = hop
|
|
if entry.get("l7_details") is not None:
|
|
result["l7_details"] = entry["l7_details"]
|
|
result["has_l7_data"] = True
|
|
elif "has_l7_data" in entry:
|
|
result["has_l7_data"] = False
|
|
return result
|
|
|
|
def _dedup_entries(entries: list) -> list:
|
|
"""Deduplicate dependency entries by logical workload identity,
|
|
collapsing replica pods that share the same Deployment/StatefulSet.
|
|
When duplicates exist, keeps the entry with the lowest hop_count
|
|
so multi-replica merges preserve the shortest path."""
|
|
seen: Dict[str, dict] = {}
|
|
for entry in entries:
|
|
key = _workload_key(entry)
|
|
if key not in seen:
|
|
seen[key] = entry
|
|
elif entry.get("hop_count", 1) < seen[key].get("hop_count", 1):
|
|
seen[key] = entry
|
|
return list(seen.values())
|
|
|
|
def _filter_and_dedup(entries: list) -> list:
|
|
"""Remove noise entries then deduplicate."""
|
|
return _dedup_entries([e for e in entries if not _is_noise_entry(e)])
|
|
|
|
def _group_by_category(entries: list, direction: str = "downstream") -> dict:
|
|
by_cat: Dict[str, list] = {}
|
|
crit_count = 0
|
|
for entry in entries:
|
|
compact = _compact_service(entry, direction)
|
|
cat = compact.pop("service_category", "") or "other"
|
|
by_cat.setdefault(cat, []).append(compact)
|
|
if compact.get("is_critical"):
|
|
crit_count += 1
|
|
return {
|
|
"total": len(entries),
|
|
"critical_count": crit_count,
|
|
"by_category": by_cat,
|
|
}
|
|
|
|
# --- Unified loop: works identically for single and multi-service ---
|
|
workload_map: Dict[str, dict] = {}
|
|
for res in results:
|
|
up = res.get("upstream", {})
|
|
ds = res.get("downstream", [])
|
|
cl = res.get("callers", [])
|
|
up_key = _workload_key(up)
|
|
up_labels = _safe_labels(up)
|
|
if up_key in workload_map:
|
|
workload_map[up_key]["_raw_downstream"].extend(ds)
|
|
workload_map[up_key]["_raw_callers"].extend(cl)
|
|
existing = workload_map[up_key]
|
|
resolved = _resolve_kind(up.get("owner_kind") or "", up_labels)
|
|
if (not existing["kind"] or existing["kind"] == "Unknown") and resolved not in ("", "Unknown"):
|
|
existing["kind"] = resolved
|
|
if not existing["annotations"] and up.get("annotations"):
|
|
existing["annotations"] = _filter_summary_annotations(up["annotations"])
|
|
else:
|
|
workload_map[up_key] = {
|
|
"name": _workload_name(up),
|
|
"namespace": up.get("namespace", ""),
|
|
"kind": _resolve_kind(up.get("owner_kind") or "", up_labels),
|
|
"annotations": _filter_summary_annotations(up.get("annotations", {})),
|
|
"labels": up_labels,
|
|
"_raw_downstream": list(ds),
|
|
"_raw_callers": list(cl),
|
|
}
|
|
|
|
# Build per-service grouped downstream/callers and collect for global summary
|
|
all_downstream_raw = []
|
|
all_callers_raw = []
|
|
matched_services = []
|
|
for wk_data in workload_map.values():
|
|
raw_ds = wk_data.pop("_raw_downstream")
|
|
raw_cl = wk_data.pop("_raw_callers")
|
|
all_downstream_raw.extend(raw_ds)
|
|
all_callers_raw.extend(raw_cl)
|
|
wk_data["downstream"] = _group_by_category(_filter_and_dedup(raw_ds), "downstream")
|
|
wk_data["callers"] = _group_by_category(_filter_and_dedup(raw_cl), "caller")
|
|
matched_services.append(wk_data)
|
|
|
|
# Global summary with cross-service deduplication
|
|
global_ds = _filter_and_dedup(all_downstream_raw)
|
|
global_cl = _filter_and_dedup(all_callers_raw)
|
|
global_ds_grouped = _group_by_category(global_ds, "downstream")
|
|
global_cl_grouped = _group_by_category(global_cl, "caller")
|
|
|
|
is_multi = len(matched_services) > 1
|
|
all_namespaces = sorted(set(s["namespace"] for s in matched_services if s["namespace"]))
|
|
|
|
if is_multi:
|
|
if len(all_namespaces) == 1:
|
|
svc_label = f"{all_namespaces[0]} ({len(matched_services)} services)"
|
|
svc_ns = all_namespaces[0]
|
|
else:
|
|
svc_label = f"{len(matched_services)} services across {len(all_namespaces)} namespaces"
|
|
svc_ns = ", ".join(all_namespaces)
|
|
service_info = {
|
|
"name": svc_label,
|
|
"namespace": svc_ns,
|
|
"kind": "",
|
|
"annotations": {},
|
|
"labels": {},
|
|
}
|
|
else:
|
|
ms = matched_services[0]
|
|
service_info = {
|
|
"name": ms["name"],
|
|
"namespace": ms["namespace"],
|
|
"kind": ms.get("kind", ""),
|
|
"annotations": ms.get("annotations", {}),
|
|
"labels": ms.get("labels", {}),
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"analysis_ids": int_ids,
|
|
"multi_service": is_multi,
|
|
"summary": {
|
|
"total_matched": len(matched_services),
|
|
"total_downstream_unique": global_ds_grouped["total"],
|
|
"total_callers_unique": global_cl_grouped["total"],
|
|
"downstream_critical_count": global_ds_grouped["critical_count"],
|
|
"callers_critical_count": global_cl_grouped["critical_count"],
|
|
},
|
|
"service": service_info,
|
|
"matched_services": matched_services,
|
|
}
|
|
|
|
def diff_pod_dependencies(
|
|
self,
|
|
analysis_id_before: str,
|
|
analysis_id_after: str,
|
|
pod_name: Optional[str] = None,
|
|
namespace: Optional[str] = None,
|
|
owner_name: Optional[str] = None,
|
|
cluster_id: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Compare dependencies between two analysis runs."""
|
|
search_kwargs = dict(
|
|
pod_name=pod_name, namespace=namespace,
|
|
owner_name=owner_name, cluster_id=cluster_id, depth=1,
|
|
)
|
|
|
|
before = self.find_pod_dependencies(analysis_id=analysis_id_before, **search_kwargs)
|
|
after = self.find_pod_dependencies(analysis_id=analysis_id_after, **search_kwargs)
|
|
|
|
def _extract_deps(result):
|
|
deps = {}
|
|
if result.get("success"):
|
|
for r in result.get("results", []):
|
|
for d in r.get("downstream", []):
|
|
key = f"{d.get('namespace', '')}/{d.get('pod_name', '')}"
|
|
deps[key] = d
|
|
return deps
|
|
|
|
before_deps = _extract_deps(before)
|
|
after_deps = _extract_deps(after)
|
|
|
|
before_keys = set(before_deps.keys())
|
|
after_keys = set(after_deps.keys())
|
|
|
|
added = []
|
|
for k in sorted(after_keys - before_keys):
|
|
d = after_deps[k]
|
|
comm = d.get("communication", {})
|
|
added.append({
|
|
"name": d.get("pod_name"), "namespace": d.get("namespace"),
|
|
"port": comm.get("port"), "protocol": comm.get("protocol"),
|
|
"service_type": comm.get("service_type"),
|
|
})
|
|
|
|
removed = []
|
|
for k in sorted(before_keys - after_keys):
|
|
d = before_deps[k]
|
|
comm = d.get("communication", {})
|
|
removed.append({
|
|
"name": d.get("pod_name"), "namespace": d.get("namespace"),
|
|
"port": comm.get("port"), "protocol": comm.get("protocol"),
|
|
"service_type": comm.get("service_type"),
|
|
})
|
|
|
|
changed = []
|
|
for k in sorted(before_keys & after_keys):
|
|
b_comm = before_deps[k].get("communication", {})
|
|
a_comm = after_deps[k].get("communication", {})
|
|
changes = []
|
|
for field in ("port", "protocol", "app_protocol", "service_type"):
|
|
bv = b_comm.get(field)
|
|
av = a_comm.get(field)
|
|
if bv != av:
|
|
changes.append(field)
|
|
if changes:
|
|
changed.append({
|
|
"name": after_deps[k].get("pod_name"),
|
|
"namespace": after_deps[k].get("namespace"),
|
|
"change": "_".join(changes) + "_changed",
|
|
"before": {f: b_comm.get(f) for f in changes},
|
|
"after": {f: a_comm.get(f) for f in changes},
|
|
})
|
|
|
|
unchanged_count = len(before_keys & after_keys) - len(changed)
|
|
service_name = owner_name or pod_name or namespace or "unknown"
|
|
|
|
return {
|
|
"success": True,
|
|
"service": service_name,
|
|
"analysis_before": analysis_id_before,
|
|
"analysis_after": analysis_id_after,
|
|
"added_dependencies": added,
|
|
"removed_dependencies": removed,
|
|
"changed_dependencies": changed,
|
|
"unchanged_count": unchanged_count,
|
|
"summary": f"{len(added)} added, {len(removed)} removed, {len(changed)} changed, {unchanged_count} unchanged",
|
|
}
|
|
|
|
def format_dependency_graph(self, result: Dict[str, Any], fmt: str = "json") -> Any:
|
|
"""Format dependency stream result as Mermaid, DOT, or JSON."""
|
|
if fmt == "json" or not result.get("success"):
|
|
return result
|
|
|
|
lines = []
|
|
for r in result.get("results", []):
|
|
upstream = r.get("upstream", {})
|
|
up_name = (upstream.get("owner_name") or upstream.get("pod_name") or "unknown").replace("-", "_")
|
|
|
|
for d in r.get("downstream", []):
|
|
name = (d.get("owner_name") or d.get("pod_name") or "unknown").replace("-", "_")
|
|
comm = d.get("communication", {})
|
|
proto = comm.get("protocol") or "TCP"
|
|
port = comm.get("port") or 0
|
|
req = comm.get("request_count") or 0
|
|
label = f"{proto}:{port} ({self._format_count(req)} req)"
|
|
lines.append((up_name, name, label, "downstream"))
|
|
|
|
for c in r.get("callers", []):
|
|
name = (c.get("owner_name") or c.get("pod_name") or "unknown").replace("-", "_")
|
|
comm = c.get("communication", {})
|
|
proto = comm.get("protocol") or "TCP"
|
|
port = comm.get("port") or 0
|
|
req = comm.get("request_count") or 0
|
|
label = f"{proto}:{port} ({self._format_count(req)} req)"
|
|
lines.append((name, up_name, label, "caller"))
|
|
|
|
if fmt == "mermaid":
|
|
out = ["graph LR"]
|
|
for src, dst, label, _ in lines:
|
|
out.append(f' {src} -->|"{label}"| {dst}')
|
|
return "\n".join(out)
|
|
|
|
if fmt == "dot":
|
|
out = ["digraph dependencies {", " rankdir=LR;"]
|
|
for src, dst, label, _ in lines:
|
|
out.append(f' {src} -> {dst} [label="{label}"];')
|
|
out.append("}")
|
|
return "\n".join(out)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def _format_count(n: int) -> str:
|
|
if n >= 1_000_000:
|
|
return f"{n / 1_000_000:.1f}M"
|
|
if n >= 1_000:
|
|
return f"{n / 1_000:.1f}K"
|
|
return str(n)
|
|
|
|
PORT_SERVICE_MAP = {
|
|
# Relational databases
|
|
5432: "postgresql",
|
|
3306: "mysql", 33060: "mysql-x",
|
|
1433: "mssql", 1434: "mssql-browser",
|
|
1521: "oracle", 1830: "oracle-net",
|
|
50000: "db2",
|
|
26257: "cockroachdb",
|
|
4000: "tidb",
|
|
5433: "postgresql", # also used by YugabyteDB; name-based detection resolves ambiguity
|
|
# NoSQL / Document
|
|
27017: "mongodb", 27018: "mongodb", 27019: "mongodb",
|
|
5984: "couchdb",
|
|
8091: "couchbase", 8092: "couchbase", 8093: "couchbase", 11210: "couchbase",
|
|
8529: "arangodb",
|
|
8086: "influxdb",
|
|
# Key-value / Cache
|
|
6379: "redis", 6380: "redis", 16379: "redis-sentinel", 26379: "redis-sentinel",
|
|
11211: "memcached",
|
|
5701: "hazelcast",
|
|
3001: "aerospike",
|
|
6060: "dragonflydb",
|
|
# Wide-column / Column-family
|
|
9042: "cassandra", 7000: "cassandra-inter", 7001: "cassandra-ssl",
|
|
9160: "cassandra-thrift",
|
|
19042: "scylladb",
|
|
16000: "hbase-master", 16020: "hbase-region",
|
|
8123: "clickhouse", 9440: "clickhouse-native",
|
|
8082: "druid",
|
|
# Graph databases
|
|
7687: "neo4j", 7474: "neo4j-http",
|
|
8182: "janusgraph",
|
|
9080: "dgraph",
|
|
# Time-series
|
|
8428: "victoriametrics",
|
|
4242: "opentsdb",
|
|
# Message brokers / Streaming
|
|
9092: "kafka", 9093: "kafka-ssl", 9094: "kafka",
|
|
5672: "rabbitmq", 15672: "rabbitmq-mgmt", 25672: "rabbitmq-dist",
|
|
4222: "nats", 6222: "nats-cluster", 8222: "nats-monitor",
|
|
61616: "activemq", 5673: "activemq-amqp",
|
|
6650: "pulsar",
|
|
9876: "rocketmq",
|
|
1883: "mqtt", 8883: "mqtt-ssl",
|
|
# Search engines
|
|
9200: "elasticsearch", 9300: "elasticsearch-transport",
|
|
7700: "meilisearch",
|
|
8983: "solr",
|
|
19530: "milvus",
|
|
6333: "qdrant", 6334: "qdrant-grpc",
|
|
# Service discovery / Config
|
|
2181: "zookeeper",
|
|
8500: "consul", 8501: "consul-https",
|
|
2379: "etcd", 2380: "etcd-peer",
|
|
8848: "nacos",
|
|
# Object storage
|
|
9000: "minio", # also used by ClickHouse native; name-based detection resolves ambiguity
|
|
# LDAP / Identity
|
|
389: "ldap", 636: "ldaps",
|
|
88: "kerberos",
|
|
# Monitoring / Observability
|
|
9090: "prometheus",
|
|
3100: "loki",
|
|
9411: "zipkin",
|
|
14268: "jaeger",
|
|
6831: "jaeger-thrift",
|
|
4317: "otlp-grpc", 4318: "otlp-http",
|
|
# HTTP / API (generic -- name-based detection refines ambiguous ports)
|
|
80: "http-api", 8080: "http-api", 8081: "http-api",
|
|
443: "https-api", 8443: "https-api",
|
|
3000: "http-api",
|
|
# gRPC
|
|
50051: "grpc", 50052: "grpc",
|
|
# DNS
|
|
53: "dns", 5353: "dns",
|
|
# SSH / FTP
|
|
22: "ssh", 21: "ftp", 990: "ftps",
|
|
# SMTP / Mail
|
|
25: "smtp", 465: "smtps", 587: "smtp-submission",
|
|
143: "imap", 993: "imaps",
|
|
}
|
|
|
|
SERVICE_CATEGORY_MAP = {
|
|
"database": {
|
|
"postgresql", "mysql", "mysql-x", "mssql", "mssql-browser",
|
|
"oracle", "oracle-net", "db2", "cockroachdb", "tidb",
|
|
"yugabytedb", "mongodb", "couchdb", "couchbase", "arangodb",
|
|
"cassandra", "cassandra-inter", "cassandra-ssl", "cassandra-thrift",
|
|
"scylladb", "hbase-master", "hbase-region", "clickhouse",
|
|
"clickhouse-native", "druid", "neo4j", "neo4j-http",
|
|
"janusgraph", "dgraph", "influxdb", "opentsdb",
|
|
"vitess", "percona", "mariadb", "singlestore", "timescaledb",
|
|
"cratedb", "voltdb", "greenplum", "citusdb", "spanner",
|
|
"cosmosdb", "dynamodb", "firestore", "fauna",
|
|
},
|
|
"cache": {
|
|
"redis", "redis-sentinel", "memcached", "hazelcast",
|
|
"aerospike", "dragonflydb", "varnish", "keydb",
|
|
},
|
|
"message_broker": {
|
|
"kafka", "kafka-ssl", "rabbitmq", "rabbitmq-mgmt", "rabbitmq-dist",
|
|
"nats", "nats-cluster", "nats-monitor", "activemq", "activemq-amqp",
|
|
"pulsar", "pulsar-http", "rocketmq", "mqtt", "mqtt-ssl",
|
|
"redpanda", "amazon-sqs", "azure-servicebus", "google-pubsub",
|
|
},
|
|
"search_engine": {
|
|
"elasticsearch", "elasticsearch-transport", "opensearch",
|
|
"solr", "meilisearch", "milvus", "qdrant", "qdrant-grpc",
|
|
"typesense", "algolia", "weaviate", "pinecone",
|
|
},
|
|
"service_discovery": {
|
|
"zookeeper", "consul", "consul-https", "etcd", "etcd-peer",
|
|
"nacos", "eureka",
|
|
},
|
|
"identity": {
|
|
"ldap", "ldaps", "kerberos", "keycloak",
|
|
"okta", "auth0",
|
|
},
|
|
"object_storage": {
|
|
"minio", "ceph", "swift",
|
|
},
|
|
"observability": {
|
|
"prometheus", "victoriametrics", "loki", "zipkin", "jaeger",
|
|
"jaeger-thrift", "otlp-grpc", "otlp-http",
|
|
"grafana", "datadog", "newrelic", "splunk",
|
|
},
|
|
"api_gateway": {
|
|
"http-api", "https-api", "grpc",
|
|
},
|
|
"mail": {
|
|
"smtp", "smtps", "smtp-submission", "imap", "imaps",
|
|
},
|
|
"dns": {"dns"},
|
|
"file_transfer": {"ssh", "ftp", "ftps"},
|
|
}
|
|
|
|
CRITICAL_CATEGORIES = frozenset({
|
|
"database", "cache", "message_broker", "search_engine",
|
|
"service_discovery", "identity", "object_storage",
|
|
})
|
|
|
|
NAME_CATEGORY_PATTERNS = {
|
|
"database": [
|
|
"postgres", "mysql", "mariadb", "mssql", "sqlserver", "oracle",
|
|
"mongo", "couch", "dynamo", "fauna", "cockroach", "tidb",
|
|
"yugabyte", "cassandra", "scylla", "hbase", "clickhouse",
|
|
"druid", "neo4j", "janusgraph", "dgraph", "arangodb",
|
|
"influx", "timescale", "opentsdb", "crate", "voltdb",
|
|
"greenplum", "citus", "spanner", "cosmos", "firestore",
|
|
"vitess", "percona", "singlestore", "database", "rds",
|
|
"-db-", "-db", "db-",
|
|
],
|
|
"cache": [
|
|
"redis", "memcache", "hazelcast", "aerospike", "dragonfly",
|
|
"varnish", "keydb", "cache",
|
|
],
|
|
"message_broker": [
|
|
"kafka", "rabbitmq", "rabbit", "nats", "activemq", "pulsar",
|
|
"rocketmq", "mqtt", "redpanda", "broker", "queue",
|
|
"messaging", "eventbus", "servicebus", "pubsub", "stream",
|
|
],
|
|
"search_engine": [
|
|
"elastic", "opensearch", "solr", "meilisearch", "milvus",
|
|
"qdrant", "typesense", "weaviate", "pinecone", "algolia",
|
|
"search",
|
|
],
|
|
"service_discovery": [
|
|
"zookeeper", "consul", "etcd", "nacos", "eureka",
|
|
"registry", "discovery",
|
|
],
|
|
"identity": [
|
|
"ldap", "keycloak", "okta", "auth0", "identity",
|
|
"iam", "sso",
|
|
],
|
|
"object_storage": [
|
|
"minio", "ceph", "swift", "s3", "blob", "storage",
|
|
],
|
|
}
|
|
|
|
@classmethod
|
|
def classify_service_category(cls, service_type: str, workload_name: str = "") -> str:
|
|
if service_type and service_type != "unknown":
|
|
if service_type in cls.SERVICE_CATEGORY_MAP:
|
|
return service_type
|
|
for category, types in cls.SERVICE_CATEGORY_MAP.items():
|
|
if service_type in types:
|
|
return category
|
|
name_lower = (workload_name or "").lower()
|
|
if name_lower:
|
|
for category, patterns in cls.NAME_CATEGORY_PATTERNS.items():
|
|
for pattern in patterns:
|
|
if pattern in name_lower:
|
|
return category
|
|
return "service"
|
|
|
|
@classmethod
|
|
def is_critical_service(cls, service_type: str, workload_name: str = "") -> bool:
|
|
return cls.classify_service_category(service_type, workload_name) in cls.CRITICAL_CATEGORIES
|
|
|
|
def _detect_service_type(self, port: int, app_protocol: str = None, workload_name: str = None) -> str:
|
|
if app_protocol:
|
|
proto_lower = str(app_protocol).lower()
|
|
if proto_lower in ("grpc", "http", "https", "dns"):
|
|
return proto_lower if proto_lower != "http" else "http-api"
|
|
if port and port in self.PORT_SERVICE_MAP:
|
|
return self.PORT_SERVICE_MAP[port]
|
|
if workload_name:
|
|
name_lower = workload_name.lower()
|
|
for category, patterns in self.NAME_CATEGORY_PATTERNS.items():
|
|
for pattern in patterns:
|
|
if pattern in name_lower:
|
|
for svc_type in self.SERVICE_CATEGORY_MAP.get(category, set()):
|
|
if pattern in svc_type:
|
|
return svc_type
|
|
return category
|
|
return "unknown"
|
|
|
|
def _build_communication_contract(
|
|
self, protocol, app_protocol, port,
|
|
request_count, bytes_transferred,
|
|
error_count, retransmit_count, avg_latency_ms,
|
|
last_seen, workload_name: str = ""
|
|
) -> dict:
|
|
port_val = int(port) if port else 0
|
|
req = int(request_count) if request_count else 0
|
|
err = int(error_count) if error_count else 0
|
|
error_rate = round((err / req) * 100, 4) if req > 0 else 0.0
|
|
svc_type = self._detect_service_type(port_val, app_protocol, workload_name)
|
|
svc_category = self.classify_service_category(svc_type, workload_name)
|
|
return {
|
|
"protocol": protocol,
|
|
"app_protocol": app_protocol,
|
|
"port": port_val,
|
|
"service_type": svc_type,
|
|
"service_category": svc_category,
|
|
"is_critical": svc_category in self.CRITICAL_CATEGORIES,
|
|
"request_count": req,
|
|
"bytes_transferred": int(bytes_transferred) if bytes_transferred else 0,
|
|
"error_count": err,
|
|
"error_rate_percent": error_rate,
|
|
"retransmit_count": int(retransmit_count) if retransmit_count else 0,
|
|
"avg_latency_ms": round(float(avg_latency_ms), 2) if avg_latency_ms else 0.0,
|
|
"last_seen": last_seen,
|
|
}
|
|
|
|
def _calculate_dependency_health(
|
|
self, request_count, error_count, retransmit_count, avg_latency_ms
|
|
) -> dict:
|
|
req = int(request_count) if request_count else 0
|
|
err = int(error_count) if error_count else 0
|
|
retx = int(retransmit_count) if retransmit_count else 0
|
|
latency = float(avg_latency_ms) if avg_latency_ms else 0.0
|
|
|
|
error_rate = (err / req) * 100 if req > 0 else 0.0
|
|
retransmit_rate = (retx / req) * 100 if req > 0 else 0.0
|
|
|
|
score = 100
|
|
risk_factors = []
|
|
|
|
if error_rate > 5:
|
|
score -= 30
|
|
risk_factors.append(f"high_error_rate:{error_rate:.2f}%")
|
|
elif error_rate > 1:
|
|
score -= 20
|
|
risk_factors.append(f"elevated_error_rate:{error_rate:.2f}%")
|
|
|
|
if retransmit_rate > 10:
|
|
score -= 20
|
|
risk_factors.append(f"high_retransmit_rate:{retransmit_rate:.2f}%")
|
|
elif retransmit_rate > 5:
|
|
score -= 10
|
|
risk_factors.append(f"elevated_retransmit_rate:{retransmit_rate:.2f}%")
|
|
|
|
if latency > 500:
|
|
score -= 25
|
|
risk_factors.append(f"very_high_latency:{latency:.1f}ms")
|
|
elif latency > 100:
|
|
score -= 15
|
|
risk_factors.append(f"high_latency:{latency:.1f}ms")
|
|
|
|
if req == 0:
|
|
score = 0
|
|
risk_factors.append("no_traffic")
|
|
|
|
score = max(0, score)
|
|
|
|
if score >= 80:
|
|
status = "healthy"
|
|
elif score >= 60:
|
|
status = "degraded"
|
|
elif score >= 30:
|
|
status = "unhealthy"
|
|
else:
|
|
status = "critical"
|
|
|
|
return {
|
|
"score": score,
|
|
"status": status,
|
|
"error_rate_percent": round(error_rate, 4),
|
|
"retransmit_rate_percent": round(retransmit_rate, 4),
|
|
"avg_latency_ms": round(latency, 2),
|
|
"risk_factors": risk_factors,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Shared helpers for logical name resolution (used by both
|
|
# format_dependency_summary and find_unified_dependencies)
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _safe_labels(entry: dict) -> dict:
|
|
"""Parse labels from entry — handles both str and dict forms."""
|
|
raw = entry.get("labels", {})
|
|
if isinstance(raw, str):
|
|
try:
|
|
return json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return {}
|
|
return raw if isinstance(raw, dict) else {}
|
|
|
|
@staticmethod
|
|
def _parse_json_field(raw) -> dict:
|
|
"""Generic JSON field parser — handles str, dict, and None forms."""
|
|
if raw is None:
|
|
return {}
|
|
if isinstance(raw, dict):
|
|
return raw
|
|
if isinstance(raw, str):
|
|
try:
|
|
parsed = json.loads(raw)
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except (json.JSONDecodeError, TypeError):
|
|
return {}
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _strip_template_hash(name: str, pth: str) -> str:
|
|
"""Strip pod-template-hash from a name, handling both
|
|
ReplicaSet names (name-HASH) and pod names (name-HASH-RANDOM)."""
|
|
if not pth:
|
|
return name
|
|
if name.endswith(f"-{pth}"):
|
|
return name[:-(len(pth) + 1)]
|
|
marker = f"-{pth}-"
|
|
idx = name.find(marker)
|
|
if idx > 0:
|
|
return name[:idx]
|
|
return name
|
|
|
|
def _resolve_logical_name(self, entry: dict) -> str:
|
|
"""Resolve logical workload name from L4 dependency entry (for L7 join).
|
|
|
|
Resolution order matches _workload_name in format_dependency_summary:
|
|
1. app.kubernetes.io/name or app label
|
|
2. owner_name (stripped of template hash)
|
|
3. pod_name (stripped of template hash)
|
|
"""
|
|
labels = self._safe_labels(entry)
|
|
|
|
name = labels.get("app.kubernetes.io/name") or labels.get("app")
|
|
if name:
|
|
return str(name)
|
|
|
|
pth = labels.get("pod-template-hash", "")
|
|
owner = entry.get("owner_name") or ""
|
|
if owner:
|
|
return self._strip_template_hash(owner, pth)
|
|
|
|
pod = entry.get("pod_name", "")
|
|
return self._strip_template_hash(pod, pth)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Unified L4+L7 dependency methods
|
|
# ------------------------------------------------------------------
|
|
|
|
def _batch_get_l7_edges(self, analysis_ids, pairs):
|
|
"""Batch L7 edge lookup.
|
|
|
|
pairs: list of dicts with keys:
|
|
src_ns, src_name, src_name_re, dst_ns, dst_name, dst_name_re
|
|
src_name_re/dst_name_re are regex-escaped versions for safe =~ matching.
|
|
|
|
Returns (all_results_dict, batch_success_count, batch_fail_count).
|
|
"""
|
|
if not pairs:
|
|
return {}, 0, 0
|
|
aids = [str(a) for a in analysis_ids]
|
|
prefixes = [f"{a}-" for a in aids]
|
|
|
|
MAX_BATCH = 500
|
|
all_results = {}
|
|
batch_success = 0
|
|
batch_fail = 0
|
|
for i in range(0, len(pairs), MAX_BATCH):
|
|
chunk = pairs[i:i + MAX_BATCH]
|
|
query = """
|
|
UNWIND $pairs AS pair
|
|
OPTIONAL MATCH (src:L7Workload)-[r:L7_COMMUNICATES_WITH]->(dst:L7Workload)
|
|
WHERE src.namespace = pair.src_ns
|
|
AND dst.namespace = pair.dst_ns
|
|
AND (src.name = pair.src_name OR src.name =~ (pair.src_name_re + '-\\\\d+$')
|
|
OR pair.src_name =~ (src.name + '-\\\\d+$'))
|
|
AND (dst.name = pair.dst_name OR dst.name =~ (pair.dst_name_re + '-\\\\d+$')
|
|
OR pair.dst_name =~ (dst.name + '-\\\\d+$'))
|
|
AND (r.analysis_id IN $aids OR ANY(p IN $prefixes WHERE r.analysis_id STARTS WITH p))
|
|
WITH pair, collect(CASE WHEN r IS NOT NULL THEN {
|
|
protocol: r.protocol,
|
|
http_method: r.http_method,
|
|
http_path: r.http_path,
|
|
request_count: coalesce(r.request_count, 0),
|
|
error_count: coalesce(r.error_count, 0),
|
|
avg_latency_ms: coalesce(r.avg_latency_ms, 0.0)
|
|
} END) AS edges
|
|
RETURN pair.src_ns + ':' + pair.src_name + '->' + pair.dst_ns + ':' + pair.dst_name AS pair_key,
|
|
[e IN edges WHERE e IS NOT NULL] AS edges
|
|
"""
|
|
result = self.execute_query(query, {"pairs": chunk, "aids": aids, "prefixes": prefixes})
|
|
if result.get("success"):
|
|
batch_success += 1
|
|
for row in result.get("data", []):
|
|
all_results[row["pair_key"]] = row.get("edges", [])
|
|
else:
|
|
batch_fail += 1
|
|
logger.warning("l7_batch_lookup_failed", extra={
|
|
"error": result.get("error"),
|
|
"batch_idx": i // MAX_BATCH,
|
|
"batch_size": len(chunk),
|
|
"analysis_ids": aids,
|
|
})
|
|
return all_results, batch_success, batch_fail
|
|
|
|
def _aggregate_l7_edges(self, raw_edges: list) -> Optional[dict]:
|
|
"""Aggregate L7 edge metrics from one or more relationship records."""
|
|
if not raw_edges:
|
|
return None
|
|
total_req = sum(e.get("request_count", 0) for e in raw_edges)
|
|
total_err = sum(e.get("error_count", 0) for e in raw_edges)
|
|
protocols = sorted(set(e.get("protocol", "") for e in raw_edges if e.get("protocol")))
|
|
avg_lat = round(sum(e.get("avg_latency_ms", 0) for e in raw_edges) / len(raw_edges), 2)
|
|
return {
|
|
"total_requests": total_req,
|
|
"total_errors": total_err,
|
|
"error_rate_percent": round((total_err / total_req * 100), 2) if total_req > 0 else 0.0,
|
|
"avg_latency_ms": avg_lat,
|
|
"protocols": protocols,
|
|
"last_observed_method": raw_edges[-1].get("http_method", ""),
|
|
"last_observed_path": raw_edges[-1].get("http_path", ""),
|
|
}
|
|
|
|
def find_unified_dependencies(self, analysis_ids=None, depth=1, include_l7=True, **kwargs):
|
|
"""Find L4 dependencies and enrich with L7 metrics at query-time."""
|
|
l4_result = self.find_pod_dependencies(analysis_ids=analysis_ids, depth=depth, **kwargs)
|
|
if not l4_result.get("success") or not include_l7:
|
|
l4_result["l7_lookup_status"] = "skipped" if not include_l7 else "n/a"
|
|
return l4_result
|
|
|
|
pairs = []
|
|
pair_index: Dict[str, list] = {}
|
|
for ri, result in enumerate(l4_result.get("results", [])):
|
|
up = result.get("upstream", {})
|
|
up_name = self._resolve_logical_name(up)
|
|
up_ns = up.get("namespace", "")
|
|
for direction in ("downstream", "callers"):
|
|
for di, dep in enumerate(result.get(direction, [])):
|
|
dep_name = self._resolve_logical_name(dep)
|
|
dep_ns = dep.get("namespace", "")
|
|
if not up_name or not dep_name:
|
|
continue
|
|
if direction == "downstream":
|
|
src_ns, src_name = up_ns, up_name
|
|
dst_ns, dst_name = dep_ns, dep_name
|
|
else:
|
|
src_ns, src_name = dep_ns, dep_name
|
|
dst_ns, dst_name = up_ns, up_name
|
|
pair_key = f"{src_ns}:{src_name}->{dst_ns}:{dst_name}"
|
|
if pair_key not in pair_index:
|
|
pairs.append({
|
|
"src_ns": src_ns, "src_name": src_name,
|
|
"src_name_re": re.escape(src_name),
|
|
"dst_ns": dst_ns, "dst_name": dst_name,
|
|
"dst_name_re": re.escape(dst_name),
|
|
})
|
|
pair_index[pair_key] = []
|
|
pair_index[pair_key].append((ri, direction, di))
|
|
|
|
l7_map, batch_success, batch_fail = self._batch_get_l7_edges(analysis_ids, pairs)
|
|
|
|
l7_pairs_matched = 0
|
|
for pair_key, locations in pair_index.items():
|
|
raw_edges = l7_map.get(pair_key, [])
|
|
l7_details = self._aggregate_l7_edges(raw_edges)
|
|
if l7_details is not None:
|
|
l7_pairs_matched += 1
|
|
for ri, direction, di in locations:
|
|
l4_result["results"][ri][direction][di]["l7_details"] = l7_details
|
|
l4_result["results"][ri][direction][di]["has_l7_data"] = l7_details is not None
|
|
|
|
if batch_fail > 0 and batch_success == 0:
|
|
status = "error"
|
|
elif batch_fail > 0 and batch_success > 0:
|
|
status = "partial"
|
|
elif not pairs:
|
|
status = "no_pairs"
|
|
else:
|
|
status = "ok"
|
|
l4_result["l7_lookup_status"] = status
|
|
l4_result["l7_pairs_checked"] = len(pairs)
|
|
l4_result["l7_pairs_matched"] = l7_pairs_matched
|
|
l4_result["l7_batch_success"] = batch_success
|
|
l4_result["l7_batch_fail"] = batch_fail
|
|
return l4_result
|
|
|
|
def _is_ip_address(self, value: str) -> bool:
|
|
"""Check if a string is a valid IP address"""
|
|
if not value:
|
|
return False
|
|
# IPv4 pattern
|
|
ipv4_pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
|
|
if re.match(ipv4_pattern, value):
|
|
parts = value.split('.')
|
|
return all(0 <= int(p) <= 255 for p in parts)
|
|
return False
|
|
|
|
def close(self):
|
|
"""Close Neo4j driver connection"""
|
|
if self.driver:
|
|
self.driver.close()
|
|
logger.info("🔌 Neo4j connection closed")
|
|
|
|
|
|
# Singleton instance
|
|
_query_engine_instance: Optional[GraphQueryEngine] = None
|
|
|
|
|
|
def get_query_engine() -> GraphQueryEngine:
|
|
"""Get singleton query engine instance"""
|
|
global _query_engine_instance
|
|
if _query_engine_instance is None:
|
|
_query_engine_instance = GraphQueryEngine()
|
|
return _query_engine_instance
|
|
|
|
|
|
# Create singleton instance for direct import
|
|
graph_query_engine = get_query_engine()
|