mirror of
https://github.com/taylanbakircioglu/flowfish.git
synced 2026-09-21 01:53:27 +00:00
6e503368f7
- Grafana Beyla DaemonSet for kernel-level HTTP/gRPC/DNS capture (passive, zero application changes, W3C traceparent header propagation) - flowfish-l7-collector in-cluster bridge: OTLP receiver + buffered pull API - L7 Ingestion Service: K8s service-proxy poll → enrich → RabbitMQ - ClickHouse l7_http_flows / l7_grpc_flows / l7_dns_flows + APM RED MVs - Neo4j L7Workload nodes + SAME_WORKLOAD cross-cluster bridges - New pages: Service Map, Trace Explorer, APM Services List, APM Service Detail - Analysis Wizard now supports L4 / L7 / Both modes with HTTP/gRPC/DNS picks - Integration Hub gains L7 dependency summary + tree-summary integrations - Multi-Cluster Management: dual-agent install (Inspector Gadget L4 + Beyla L7), runtime OpenShift detection so SCCs auto-install with kubectl too - ServiceMap edge → Trace Explorer drill-down with virtual_trace_id correlation - Docs: new L7 architecture diagram, README L7 sections, 3 new screenshots
398 lines
14 KiB
Python
398 lines
14 KiB
Python
"""
|
|
Graph Writer Service
|
|
Main entry point
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import asyncio
|
|
import uvloop
|
|
|
|
from app.config import settings
|
|
from app.rabbitmq_consumer import consumer
|
|
from app.graph_client import graph_client
|
|
from app.graph_builder import GraphBuilder
|
|
from app.deleted_analysis_cache import deleted_analysis_cache
|
|
from app.l7_graph_builder import (
|
|
handle_l7_http_flow,
|
|
handle_l7_grpc_flow,
|
|
handle_l7_dns_flow,
|
|
l7_edge_buffer,
|
|
)
|
|
from app.l7_same_workload import run_same_workload_periodic
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=getattr(logging, settings.log_level.upper()),
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Use uvloop for better performance
|
|
uvloop.install()
|
|
|
|
# Graph builder instance
|
|
graph_builder = GraphBuilder()
|
|
|
|
# Batch buffers
|
|
vertex_buffer = []
|
|
edge_buffer = []
|
|
last_flush_time = 0.0 # Will be initialized in main()
|
|
|
|
|
|
async def flush_buffers():
|
|
"""Flush buffers to graph database"""
|
|
global vertex_buffer, edge_buffer, last_flush_time
|
|
|
|
try:
|
|
vertices_count = len(vertex_buffer)
|
|
if vertex_buffer:
|
|
logger.info(f"Flushing {vertices_count} vertices to graph database")
|
|
result = graph_client.batch_upsert_vertices(vertex_buffer)
|
|
logger.info(f"Vertices flush complete: {result} upserted")
|
|
vertex_buffer = []
|
|
|
|
# Flush cached edges from graph builder
|
|
cached_edges = graph_builder.flush_edges()
|
|
edges_count = len(cached_edges) if cached_edges else 0
|
|
if cached_edges:
|
|
logger.info(f"Flushing {edges_count} edges to graph database")
|
|
result = graph_client.batch_upsert_edges(cached_edges)
|
|
logger.info(f"Edges flush complete: {result} upserted")
|
|
|
|
# Log summary with event counts
|
|
if vertices_count > 0 or edges_count > 0:
|
|
logger.info(f"Flush summary - vertices: {vertices_count}, edges: {edges_count}, total_network_flow_received: {event_counts.get('network_flow_received', 0)}")
|
|
|
|
last_flush_time = asyncio.get_event_loop().time()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to flush buffers: {e}", exc_info=True)
|
|
|
|
|
|
# Event counters for diagnostics
|
|
event_counts = {
|
|
"network_flow_received": 0,
|
|
"network_flow_processed": 0,
|
|
"dns_query_received": 0,
|
|
"dns_query_processed": 0,
|
|
"tcp_connection_received": 0,
|
|
"tcp_connection_processed": 0,
|
|
"bind_event_received": 0,
|
|
"bind_event_processed": 0,
|
|
"sni_event_received": 0,
|
|
"sni_event_processed": 0,
|
|
}
|
|
|
|
async def handle_network_flow(data: dict):
|
|
"""Handle network flow event"""
|
|
global event_counts
|
|
event_counts["network_flow_received"] += 1
|
|
|
|
try:
|
|
# Log every 100th event for diagnostics
|
|
if event_counts["network_flow_received"] % 100 == 0:
|
|
logger.info(f"Network flow events received: {event_counts['network_flow_received']}, processed: {event_counts['network_flow_processed']}")
|
|
|
|
# Check if analysis has been deleted - skip orphan data
|
|
analysis_id = data.get('analysis_id')
|
|
if analysis_id and deleted_analysis_cache.is_deleted(str(analysis_id)):
|
|
logger.debug(f"Skipping network flow for deleted analysis {analysis_id}")
|
|
return
|
|
|
|
vertices, edges = graph_builder.process_network_flow(data)
|
|
|
|
# Log first few events with details for diagnostics
|
|
if event_counts["network_flow_received"] <= 5:
|
|
logger.info(f"Network flow event {event_counts['network_flow_received']}: analysis_id={analysis_id}, vertices={len(vertices)}, edges_cached={len(graph_builder.edge_cache)}")
|
|
|
|
vertex_buffer.extend(vertices)
|
|
edge_buffer.extend(edges)
|
|
event_counts["network_flow_processed"] += 1
|
|
|
|
# Check if we should flush
|
|
current_time = asyncio.get_event_loop().time()
|
|
if (len(vertex_buffer) >= settings.batch_size or
|
|
(current_time - last_flush_time) >= settings.flush_interval):
|
|
await flush_buffers()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to handle network flow: {e}", exc_info=True)
|
|
|
|
|
|
async def handle_dns_query(data: dict):
|
|
"""Handle DNS query event"""
|
|
try:
|
|
# Check if analysis has been deleted
|
|
analysis_id = data.get('analysis_id')
|
|
if analysis_id and deleted_analysis_cache.is_deleted(str(analysis_id)):
|
|
return
|
|
|
|
vertices, edges = graph_builder.process_dns_query(data)
|
|
vertex_buffer.extend(vertices)
|
|
edge_buffer.extend(edges)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to handle DNS query: {e}")
|
|
|
|
|
|
async def handle_tcp_connection(data: dict):
|
|
"""Handle TCP connection event"""
|
|
try:
|
|
# Check if analysis has been deleted
|
|
analysis_id = data.get('analysis_id')
|
|
if analysis_id and deleted_analysis_cache.is_deleted(str(analysis_id)):
|
|
return
|
|
|
|
vertices, edges = graph_builder.process_tcp_connection(data)
|
|
vertex_buffer.extend(vertices)
|
|
edge_buffer.extend(edges)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to handle TCP connection: {e}")
|
|
|
|
|
|
async def handle_bind_event(data: dict):
|
|
"""Handle bind event - shows services listening on ports"""
|
|
try:
|
|
# Check if analysis has been deleted
|
|
analysis_id = data.get('analysis_id')
|
|
if analysis_id and deleted_analysis_cache.is_deleted(str(analysis_id)):
|
|
return
|
|
|
|
vertices, edges = graph_builder.process_bind_event(data)
|
|
vertex_buffer.extend(vertices)
|
|
edge_buffer.extend(edges)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to handle bind event: {e}")
|
|
|
|
|
|
async def handle_sni_event(data: dict):
|
|
"""Handle SNI event - shows TLS connections to external services"""
|
|
try:
|
|
# Check if analysis has been deleted
|
|
analysis_id = data.get('analysis_id')
|
|
if analysis_id and deleted_analysis_cache.is_deleted(str(analysis_id)):
|
|
return
|
|
|
|
vertices, edges = graph_builder.process_sni_event(data)
|
|
vertex_buffer.extend(vertices)
|
|
edge_buffer.extend(edges)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to handle SNI event: {e}")
|
|
|
|
|
|
async def handle_l7_http_flow_message(data: dict):
|
|
"""L7 HTTP flow — L7Workload nodes and L7_COMMUNICATES_WITH in Neo4j."""
|
|
handle_l7_http_flow(data, graph_client)
|
|
|
|
|
|
async def handle_l7_grpc_flow_message(data: dict):
|
|
"""L7 gRPC flow — L7Workload nodes and L7_COMMUNICATES_WITH in Neo4j."""
|
|
handle_l7_grpc_flow(data, graph_client)
|
|
|
|
|
|
async def handle_l7_dns_flow_message(data: dict):
|
|
"""L7 DNS flow — L7Workload nodes and L7_COMMUNICATES_WITH in Neo4j."""
|
|
try:
|
|
handle_l7_dns_flow(data, graph_client, graph_builder)
|
|
except Exception as e:
|
|
logger.error(f"Failed to handle L7 DNS flow: {e}", exc_info=True)
|
|
|
|
|
|
async def periodic_flush():
|
|
"""Periodically flush buffers.
|
|
|
|
Each iteration is wrapped in try/except so a transient Neo4j or executor
|
|
error does not terminate the entire periodic loop. The SAME_WORKLOAD task
|
|
runs sequentially after the L4/L7 dedup pass to avoid lock contention on
|
|
the same L7Workload nodes.
|
|
"""
|
|
same_workload_counter = 0
|
|
while True:
|
|
await asyncio.sleep(settings.flush_interval)
|
|
try:
|
|
await flush_buffers()
|
|
await asyncio.get_event_loop().run_in_executor(
|
|
None, l7_edge_buffer.flush_if_needed, graph_client
|
|
)
|
|
# SAME_WORKLOAD periodic — runs every N flush cycles when tracing
|
|
# is enabled. Sequential (not parallel) so it never contends with
|
|
# the L7 dedup pass that may be writing to the same nodes.
|
|
if getattr(settings, "l7_tracing_enabled", False):
|
|
same_workload_counter += 1
|
|
cycles = max(1, settings.same_workload_interval // max(1, settings.flush_interval))
|
|
if same_workload_counter >= cycles:
|
|
same_workload_counter = 0
|
|
# Thread-safe snapshot — `_flush()` in the executor thread
|
|
# may mutate `_seen_analysis_ids` concurrently.
|
|
aids = l7_edge_buffer.snapshot_seen_analysis_ids()
|
|
if aids:
|
|
await asyncio.get_event_loop().run_in_executor(
|
|
None, run_same_workload_periodic, graph_client, aids
|
|
)
|
|
except Exception:
|
|
logger.error("periodic_flush iteration failed", exc_info=True)
|
|
|
|
|
|
def _parse_resolv_conf() -> list:
|
|
"""Read /etc/resolv.conf and extract search domains.
|
|
|
|
Per POSIX, if multiple 'search' directives exist, the LAST one wins.
|
|
"""
|
|
search_domains: list = []
|
|
try:
|
|
with open('/etc/resolv.conf', 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line.startswith('search '):
|
|
search_domains = line.split()[1:]
|
|
except (FileNotFoundError, PermissionError):
|
|
pass
|
|
return search_domains
|
|
|
|
|
|
async def refresh_dns_config():
|
|
"""Fetch DNS config from backend API and merge with resolv.conf.
|
|
|
|
Domain sources (merged):
|
|
1. API: Only enabled, NON-default custom domains (K8s defaults handled by hardcoded logic)
|
|
2. resolv.conf: All search domains from the pod's /etc/resolv.conf
|
|
3. Env var: DNS_SEARCH_DOMAINS (backward compatibility fallback)
|
|
"""
|
|
import aiohttp
|
|
backend_url = f"http://{settings.backend_service_host}:{settings.backend_service_port}"
|
|
|
|
api_domains: list = []
|
|
try:
|
|
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=5)) as session:
|
|
async with session.get(f"{backend_url}/api/v1/settings/dns-config/defaults") as resp:
|
|
if resp.status == 200:
|
|
data = await resp.json()
|
|
api_domains = [
|
|
d['domain'] for d in data.get('search_domains', [])
|
|
if d.get('enabled', True) and not d.get('is_default', False)
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch DNS config from backend: {e}")
|
|
|
|
resolv_domains = _parse_resolv_conf()
|
|
all_domains = set(api_domains) | set(resolv_domains)
|
|
|
|
env_raw = os.environ.get('DNS_SEARCH_DOMAINS', '')
|
|
if env_raw:
|
|
all_domains |= set(d.strip() for d in env_raw.split(',') if d.strip())
|
|
|
|
graph_builder.update_search_domains(list(all_domains))
|
|
|
|
if all_domains:
|
|
logger.info(f"DNS search domains updated ({len(all_domains)} domains): {sorted(all_domains)}")
|
|
else:
|
|
logger.warning("DNS search domains list is empty — custom domain normalization will rely on env var fallback")
|
|
|
|
|
|
async def periodic_dns_config_refresh():
|
|
"""Periodically refresh DNS search domain config."""
|
|
while True:
|
|
try:
|
|
await refresh_dns_config()
|
|
except Exception as e:
|
|
logger.error(f"DNS config refresh failed: {e}")
|
|
await asyncio.sleep(60)
|
|
|
|
|
|
async def main():
|
|
"""Main function"""
|
|
global last_flush_time
|
|
|
|
logger.info("Starting Graph Writer Service...")
|
|
logger.info(f"Service: {settings.service_name}")
|
|
logger.info(f"RabbitMQ: {settings.rabbitmq_host}:{settings.rabbitmq_port}")
|
|
logger.info(f"Graph Database: {settings.neo4j_bolt_uri}")
|
|
|
|
# Initialize last_flush_time
|
|
last_flush_time = asyncio.get_event_loop().time()
|
|
|
|
# Connect to RabbitMQ
|
|
await consumer.connect()
|
|
|
|
# Register handlers for all graph-relevant event types
|
|
consumer.register_handler(settings.queue_network_flows, handle_network_flow)
|
|
consumer.register_handler(settings.queue_dns_queries, handle_dns_query)
|
|
consumer.register_handler(settings.queue_tcp_connections, handle_tcp_connection)
|
|
consumer.register_handler(settings.queue_bind_events, handle_bind_event)
|
|
consumer.register_handler(settings.queue_sni_events, handle_sni_event)
|
|
|
|
if settings.l7_enabled:
|
|
consumer.register_handler(settings.queue_l7_http_flows, handle_l7_http_flow_message)
|
|
consumer.register_handler(settings.queue_l7_grpc_flows, handle_l7_grpc_flow_message)
|
|
consumer.register_handler(settings.queue_l7_dns_flows, handle_l7_dns_flow_message)
|
|
logger.info(
|
|
"L7 graph consumers ENABLED: %s, %s, %s",
|
|
settings.queue_l7_http_flows,
|
|
settings.queue_l7_grpc_flows,
|
|
settings.queue_l7_dns_flows,
|
|
)
|
|
|
|
logger.info(
|
|
f"Queues registered: {settings.queue_network_flows}, {settings.queue_dns_queries}, "
|
|
f"{settings.queue_tcp_connections}, {settings.queue_bind_events}, {settings.queue_sni_events}"
|
|
+ (
|
|
f", {settings.queue_l7_http_flows}, {settings.queue_l7_grpc_flows}, {settings.queue_l7_dns_flows}"
|
|
if settings.l7_enabled
|
|
else ""
|
|
)
|
|
)
|
|
|
|
# Refresh DNS search domains at startup, then periodically
|
|
await refresh_dns_config()
|
|
dns_refresh_task = asyncio.create_task(periodic_dns_config_refresh())
|
|
|
|
# Start periodic flush task
|
|
flush_task = asyncio.create_task(periodic_flush())
|
|
|
|
# Start consuming
|
|
try:
|
|
await consumer.consume_all_queues()
|
|
except KeyboardInterrupt:
|
|
logger.info("Interrupted by user")
|
|
finally:
|
|
flush_task.cancel()
|
|
dns_refresh_task.cancel()
|
|
logger.info("Flushing remaining buffers before shutdown...")
|
|
try:
|
|
await flush_buffers()
|
|
except Exception as e:
|
|
logger.error("L4 shutdown flush failed: %s", e)
|
|
try:
|
|
await asyncio.get_event_loop().run_in_executor(
|
|
None, l7_edge_buffer.force_flush, graph_client
|
|
)
|
|
except Exception as e:
|
|
logger.error("L7 shutdown flush failed: %s", e)
|
|
await consumer.close()
|
|
graph_client.close()
|
|
|
|
|
|
def signal_handler(sig, frame):
|
|
"""Handle shutdown signals — raise KeyboardInterrupt so the main loop's
|
|
finally block runs (flush L7 buffer, close connections)."""
|
|
logger.info("Shutting down gracefully (signal %s)...", sig)
|
|
raise KeyboardInterrupt
|
|
|
|
|
|
if __name__ == '__main__':
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
try:
|
|
asyncio.run(main())
|
|
except KeyboardInterrupt:
|
|
logger.info("Service stopped")
|
|
|