mirror of
https://github.com/taylanbakircioglu/flowfish.git
synced 2026-09-12 05:48:55 +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
3467 lines
126 KiB
Python
3467 lines
126 KiB
Python
"""
|
|
Changes Router - Change Detection and Tracking API
|
|
|
|
Version: 2.0.0
|
|
Last Updated: 2026-01-08
|
|
|
|
This router provides endpoints for:
|
|
- Tracking infrastructure changes over time
|
|
- Comparing analysis snapshots
|
|
- Detecting workload and connection changes
|
|
|
|
ARCHITECTURE NOTE:
|
|
- Change events are stored ONLY in ClickHouse (PostgreSQL change_events table removed)
|
|
- All queries go directly to ClickHouse for better performance
|
|
- Run-based filtering supported for multi-cycle analyses
|
|
"""
|
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
|
from typing import Optional, List, Dict, Any
|
|
from datetime import datetime, timedelta
|
|
from pydantic import BaseModel, Field
|
|
from enum import Enum
|
|
import re
|
|
import structlog
|
|
import os
|
|
import json
|
|
|
|
from utils.jwt_utils import require_permissions, get_current_user
|
|
from services.change_detection_service import (
|
|
ChangeDetectionService,
|
|
get_change_detection_service,
|
|
ChangeType as ServiceChangeType,
|
|
RiskLevel as ServiceRiskLevel
|
|
)
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
router = APIRouter()
|
|
|
|
# Validate change_id (UUID) at the API boundary. Without this, accidental
|
|
# URLs like `/changes/detected` (typo, stale frontend route, or copy/paste)
|
|
# hit the `/changes/{change_id}` path parameter and reach ClickHouse with
|
|
# a non-UUID literal — ClickHouse then surfaces a multi-page stack trace
|
|
# as a 500. Pre-validating gives the operator a clean 400 instead.
|
|
_CHANGE_ID_UUID_RE = re.compile(
|
|
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
)
|
|
|
|
|
|
def _validate_change_id(change_id: str) -> str:
|
|
if not change_id or not _CHANGE_ID_UUID_RE.match(change_id):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
"Invalid change_id: expected a UUID (e.g. "
|
|
"'550e8400-e29b-41d4-a716-446655440000')"
|
|
),
|
|
)
|
|
return change_id
|
|
|
|
# Feature flags
|
|
RUN_BASED_FILTERING = os.getenv("RUN_BASED_FILTERING_ENABLED", "true").lower() == "true"
|
|
|
|
|
|
async def _get_ch_analysis_id(analysis_id: int, cluster_id: Optional[int] = None) -> str:
|
|
"""Format analysis_id for ClickHouse queries.
|
|
|
|
The change-detection worker stores analysis_id as '{id}-{cluster_id}'
|
|
in ClickHouse. This helper resolves the cluster_id when not provided.
|
|
"""
|
|
if cluster_id:
|
|
return f"{analysis_id}-{cluster_id}"
|
|
from database.postgresql import database
|
|
row = await database.fetch_one(
|
|
"SELECT cluster_id FROM analyses WHERE id = :aid",
|
|
{"aid": analysis_id},
|
|
)
|
|
if row:
|
|
return f"{analysis_id}-{row['cluster_id']}"
|
|
return str(analysis_id)
|
|
|
|
|
|
# ============ Enums ============
|
|
|
|
class ChangeType(str, Enum):
|
|
# Legacy types (backward compatibility)
|
|
WORKLOAD_ADDED = "workload_added"
|
|
WORKLOAD_REMOVED = "workload_removed"
|
|
NAMESPACE_CHANGED = "namespace_changed"
|
|
# Infrastructure changes (K8s API) - Workloads
|
|
REPLICA_CHANGED = "replica_changed"
|
|
CONFIG_CHANGED = "config_changed"
|
|
IMAGE_CHANGED = "image_changed"
|
|
LABEL_CHANGED = "label_changed"
|
|
RESOURCE_CHANGED = "resource_changed"
|
|
ENV_CHANGED = "env_changed"
|
|
SPEC_CHANGED = "spec_changed"
|
|
# Infrastructure changes (K8s API) - Services
|
|
SERVICE_PORT_CHANGED = "service_port_changed"
|
|
SERVICE_SELECTOR_CHANGED = "service_selector_changed"
|
|
SERVICE_TYPE_CHANGED = "service_type_changed"
|
|
SERVICE_ADDED = "service_added"
|
|
SERVICE_REMOVED = "service_removed"
|
|
# Infrastructure changes (K8s API) - Network / Ingress / Route
|
|
NETWORK_POLICY_ADDED = "network_policy_added"
|
|
NETWORK_POLICY_REMOVED = "network_policy_removed"
|
|
NETWORK_POLICY_CHANGED = "network_policy_changed"
|
|
INGRESS_ADDED = "ingress_added"
|
|
INGRESS_REMOVED = "ingress_removed"
|
|
INGRESS_CHANGED = "ingress_changed"
|
|
ROUTE_ADDED = "route_added"
|
|
ROUTE_REMOVED = "route_removed"
|
|
ROUTE_CHANGED = "route_changed"
|
|
# Behavioral changes (eBPF) - Connections
|
|
CONNECTION_ADDED = "connection_added"
|
|
CONNECTION_REMOVED = "connection_removed"
|
|
PORT_CHANGED = "port_changed"
|
|
# Behavioral changes (eBPF) - Anomalies
|
|
TRAFFIC_ANOMALY = "traffic_anomaly"
|
|
DNS_ANOMALY = "dns_anomaly"
|
|
PROCESS_ANOMALY = "process_anomaly"
|
|
ERROR_ANOMALY = "error_anomaly"
|
|
|
|
|
|
class RiskLevel(str, Enum):
|
|
CRITICAL = "critical"
|
|
HIGH = "high"
|
|
MEDIUM = "medium"
|
|
LOW = "low"
|
|
|
|
|
|
# ============ Schemas ============
|
|
|
|
class Change(BaseModel):
|
|
id: str # UUID from ClickHouse
|
|
timestamp: datetime
|
|
change_type: ChangeType
|
|
target: str
|
|
namespace: str
|
|
details: str
|
|
risk: RiskLevel
|
|
affected_services: int = 0
|
|
changed_by: str = "auto-discovery"
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
class ChangeStats(BaseModel):
|
|
total_changes: int
|
|
by_type: Dict[str, int]
|
|
by_risk: Dict[str, int]
|
|
by_namespace: Dict[str, int]
|
|
|
|
|
|
class SnapshotComparison(BaseModel):
|
|
before: Dict[str, int]
|
|
after: Dict[str, int]
|
|
summary: Dict[str, int]
|
|
|
|
|
|
class ChangesResponse(BaseModel):
|
|
changes: List[Change]
|
|
total: int
|
|
stats: ChangeStats
|
|
comparison: SnapshotComparison
|
|
|
|
|
|
# ============ Risk Assessment ============
|
|
|
|
def assess_change_risk(change_type: ChangeType, affected_count: int = 0) -> RiskLevel:
|
|
"""Determine risk level based on change type and impact"""
|
|
|
|
# Critical-potential changes (routing/connectivity disruption)
|
|
if change_type in [
|
|
ChangeType.WORKLOAD_REMOVED, ChangeType.SERVICE_REMOVED,
|
|
ChangeType.SERVICE_SELECTOR_CHANGED, ChangeType.NETWORK_POLICY_CHANGED,
|
|
ChangeType.NETWORK_POLICY_REMOVED, ChangeType.PORT_CHANGED,
|
|
]:
|
|
if affected_count > 5:
|
|
return RiskLevel.CRITICAL
|
|
elif affected_count > 2:
|
|
return RiskLevel.HIGH
|
|
return RiskLevel.MEDIUM
|
|
|
|
# High-potential changes (service behavior modification)
|
|
if change_type in [
|
|
ChangeType.SERVICE_PORT_CHANGED, ChangeType.SERVICE_TYPE_CHANGED,
|
|
ChangeType.IMAGE_CHANGED, ChangeType.INGRESS_CHANGED,
|
|
ChangeType.INGRESS_REMOVED, ChangeType.ROUTE_CHANGED,
|
|
ChangeType.ROUTE_REMOVED, ChangeType.ENV_CHANGED,
|
|
]:
|
|
if affected_count > 5:
|
|
return RiskLevel.HIGH
|
|
return RiskLevel.MEDIUM
|
|
|
|
# Medium risk changes (configuration/operational)
|
|
if change_type in [
|
|
ChangeType.CONNECTION_REMOVED, ChangeType.CONFIG_CHANGED,
|
|
ChangeType.RESOURCE_CHANGED, ChangeType.SPEC_CHANGED,
|
|
ChangeType.NETWORK_POLICY_ADDED,
|
|
]:
|
|
if affected_count > 5:
|
|
return RiskLevel.HIGH
|
|
return RiskLevel.MEDIUM
|
|
|
|
# Low risk changes (additive / informational)
|
|
if change_type in [
|
|
ChangeType.WORKLOAD_ADDED, ChangeType.CONNECTION_ADDED,
|
|
ChangeType.REPLICA_CHANGED, ChangeType.LABEL_CHANGED,
|
|
ChangeType.SERVICE_ADDED, ChangeType.INGRESS_ADDED,
|
|
ChangeType.ROUTE_ADDED, ChangeType.NAMESPACE_CHANGED,
|
|
]:
|
|
return RiskLevel.LOW
|
|
|
|
# Anomalies — severity depends on type and blast radius
|
|
if change_type in [
|
|
ChangeType.ERROR_ANOMALY, ChangeType.PROCESS_ANOMALY,
|
|
]:
|
|
if affected_count > 5:
|
|
return RiskLevel.HIGH
|
|
return RiskLevel.MEDIUM
|
|
|
|
if change_type in [
|
|
ChangeType.TRAFFIC_ANOMALY, ChangeType.DNS_ANOMALY,
|
|
]:
|
|
if affected_count > 5:
|
|
return RiskLevel.MEDIUM
|
|
return RiskLevel.LOW
|
|
|
|
return RiskLevel.MEDIUM
|
|
|
|
|
|
# ============ Mock Data Generation ============
|
|
|
|
def generate_mock_changes(
|
|
cluster_id: int,
|
|
start_time: Optional[datetime] = None,
|
|
end_time: Optional[datetime] = None,
|
|
change_types: Optional[List[str]] = None,
|
|
risk_levels: Optional[List[str]] = None
|
|
) -> List[Change]:
|
|
"""Generate realistic mock change data for MVP"""
|
|
|
|
if not end_time:
|
|
end_time = datetime.utcnow()
|
|
if not start_time:
|
|
start_time = end_time - timedelta(hours=24)
|
|
|
|
# Sample changes with realistic data
|
|
mock_data = [
|
|
{
|
|
"id": 1,
|
|
"timestamp": end_time - timedelta(minutes=30),
|
|
"change_type": ChangeType.WORKLOAD_ADDED,
|
|
"target": "payment-service",
|
|
"namespace": "production",
|
|
"details": "New deployment created with 3 replicas",
|
|
"affected_services": 5,
|
|
"changed_by": "deploy-bot",
|
|
},
|
|
{
|
|
"id": 2,
|
|
"timestamp": end_time - timedelta(minutes=45),
|
|
"change_type": ChangeType.CONNECTION_ADDED,
|
|
"target": "api-gateway → payment-service",
|
|
"namespace": "production",
|
|
"details": "New TCP connection on port 8080",
|
|
"affected_services": 2,
|
|
"changed_by": "auto-discovery",
|
|
},
|
|
{
|
|
"id": 3,
|
|
"timestamp": end_time - timedelta(hours=1),
|
|
"change_type": ChangeType.WORKLOAD_REMOVED,
|
|
"target": "legacy-auth-service",
|
|
"namespace": "production",
|
|
"details": "Deployment deleted, 0 replicas remaining",
|
|
"affected_services": 8,
|
|
"changed_by": "admin@company.com",
|
|
},
|
|
{
|
|
"id": 4,
|
|
"timestamp": end_time - timedelta(hours=1, minutes=30),
|
|
"change_type": ChangeType.PORT_CHANGED,
|
|
"target": "database-primary",
|
|
"namespace": "data",
|
|
"details": "Port changed from 5432 to 5433",
|
|
"affected_services": 12,
|
|
"changed_by": "dba@company.com",
|
|
},
|
|
{
|
|
"id": 5,
|
|
"timestamp": end_time - timedelta(hours=2),
|
|
"change_type": ChangeType.CONFIG_CHANGED,
|
|
"target": "redis-cache",
|
|
"namespace": "cache",
|
|
"details": "ConfigMap updated: max_connections increased to 10000",
|
|
"affected_services": 3,
|
|
"changed_by": "ops-team",
|
|
},
|
|
{
|
|
"id": 6,
|
|
"timestamp": end_time - timedelta(hours=3),
|
|
"change_type": ChangeType.CONNECTION_REMOVED,
|
|
"target": "frontend → legacy-auth-service",
|
|
"namespace": "production",
|
|
"details": "Connection no longer observed after service removal",
|
|
"affected_services": 1,
|
|
"changed_by": "auto-discovery",
|
|
},
|
|
{
|
|
"id": 7,
|
|
"timestamp": end_time - timedelta(hours=4),
|
|
"change_type": ChangeType.REPLICA_CHANGED,
|
|
"target": "worker-service",
|
|
"namespace": "jobs",
|
|
"details": "Replicas scaled from 2 to 5 by HPA",
|
|
"affected_services": 0,
|
|
"changed_by": "hpa-controller",
|
|
},
|
|
{
|
|
"id": 8,
|
|
"timestamp": end_time - timedelta(hours=5),
|
|
"change_type": ChangeType.WORKLOAD_ADDED,
|
|
"target": "notification-service",
|
|
"namespace": "messaging",
|
|
"details": "New microservice deployed with 2 replicas",
|
|
"affected_services": 3,
|
|
"changed_by": "ci-pipeline",
|
|
},
|
|
{
|
|
"id": 9,
|
|
"timestamp": end_time - timedelta(hours=6),
|
|
"change_type": ChangeType.CONNECTION_ADDED,
|
|
"target": "notification-service → kafka-broker",
|
|
"namespace": "messaging",
|
|
"details": "New Kafka producer connection on port 9092",
|
|
"affected_services": 1,
|
|
"changed_by": "auto-discovery",
|
|
},
|
|
{
|
|
"id": 10,
|
|
"timestamp": end_time - timedelta(hours=8),
|
|
"change_type": ChangeType.NAMESPACE_CHANGED,
|
|
"target": "monitoring-agent",
|
|
"namespace": "observability",
|
|
"details": "Moved from 'kube-system' to 'observability' namespace",
|
|
"affected_services": 4,
|
|
"changed_by": "platform-team",
|
|
},
|
|
]
|
|
|
|
changes = []
|
|
for data in mock_data:
|
|
change = Change(
|
|
id=str(data["id"]), # Convert to string
|
|
timestamp=data["timestamp"],
|
|
change_type=data["change_type"],
|
|
target=data["target"],
|
|
namespace=data["namespace"],
|
|
details=data["details"],
|
|
risk=assess_change_risk(data["change_type"], data["affected_services"]),
|
|
affected_services=data["affected_services"],
|
|
changed_by=data["changed_by"],
|
|
)
|
|
|
|
# Filter by time range
|
|
if change.timestamp < start_time or change.timestamp > end_time:
|
|
continue
|
|
|
|
# Filter by change types
|
|
if change_types and change.change_type.value not in change_types:
|
|
continue
|
|
|
|
# Filter by risk levels
|
|
if risk_levels and change.risk.value not in risk_levels:
|
|
continue
|
|
|
|
changes.append(change)
|
|
|
|
return sorted(changes, key=lambda x: x.timestamp, reverse=True)
|
|
|
|
|
|
def calculate_stats(changes: List[Change]) -> ChangeStats:
|
|
"""Calculate statistics from changes"""
|
|
by_type: Dict[str, int] = {}
|
|
by_risk: Dict[str, int] = {}
|
|
by_namespace: Dict[str, int] = {}
|
|
|
|
for change in changes:
|
|
by_type[change.change_type.value] = by_type.get(change.change_type.value, 0) + 1
|
|
by_risk[change.risk.value] = by_risk.get(change.risk.value, 0) + 1
|
|
by_namespace[change.namespace] = by_namespace.get(change.namespace, 0) + 1
|
|
|
|
return ChangeStats(
|
|
total_changes=len(changes),
|
|
by_type=by_type,
|
|
by_risk=by_risk,
|
|
by_namespace=by_namespace,
|
|
)
|
|
|
|
|
|
# Helper functions for change detail enrichment
|
|
def _get_severity_indicator(risk_level: str, affected_services: int, blast_radius: int) -> dict:
|
|
"""Calculate severity indicator for UI display"""
|
|
score = 0
|
|
factors = []
|
|
|
|
# Risk level contribution
|
|
risk_scores = {"critical": 40, "high": 30, "medium": 15, "low": 5}
|
|
score += risk_scores.get(risk_level, 10)
|
|
if risk_level in ["critical", "high"]:
|
|
factors.append(f"Risk level: {risk_level}")
|
|
|
|
# Affected services contribution
|
|
if affected_services > 10:
|
|
score += 30
|
|
factors.append(f"High impact: {affected_services} services affected")
|
|
elif affected_services > 5:
|
|
score += 20
|
|
factors.append(f"Medium impact: {affected_services} services affected")
|
|
elif affected_services > 0:
|
|
score += 10
|
|
factors.append(f"Low impact: {affected_services} services affected")
|
|
|
|
# Blast radius contribution
|
|
if blast_radius > 20:
|
|
score += 30
|
|
factors.append(f"Large blast radius: {blast_radius}")
|
|
elif blast_radius > 10:
|
|
score += 15
|
|
factors.append(f"Medium blast radius: {blast_radius}")
|
|
|
|
# Determine level
|
|
if score >= 70:
|
|
level = "critical"
|
|
color = "#cf1322"
|
|
elif score >= 50:
|
|
level = "high"
|
|
color = "#c75450"
|
|
elif score >= 30:
|
|
level = "medium"
|
|
color = "#b89b5d"
|
|
else:
|
|
level = "low"
|
|
color = "#4d9f7c"
|
|
|
|
return {
|
|
"score": min(score, 100),
|
|
"level": level,
|
|
"color": color,
|
|
"factors": factors
|
|
}
|
|
|
|
|
|
def _get_change_category(change_type: str) -> dict:
|
|
"""Categorize change type for UI grouping"""
|
|
categories = {
|
|
"infrastructure": {
|
|
"types": [
|
|
"replica_changed", "config_changed", "image_changed", "label_changed",
|
|
"workload_added", "workload_removed", "namespace_changed",
|
|
"resource_changed", "env_changed", "spec_changed",
|
|
"service_port_changed", "service_selector_changed", "service_type_changed",
|
|
"service_added", "service_removed",
|
|
"network_policy_added", "network_policy_removed", "network_policy_changed",
|
|
"ingress_added", "ingress_removed", "ingress_changed",
|
|
"route_added", "route_removed", "route_changed",
|
|
],
|
|
"icon": "CloudServerOutlined",
|
|
"color": "#0891b2",
|
|
"description": "Infrastructure changes affect workload configurations and deployments"
|
|
},
|
|
"network": {
|
|
"types": ["connection_added", "connection_removed", "port_changed"],
|
|
"icon": "ApiOutlined",
|
|
"color": "#4d9f7c",
|
|
"description": "Network changes affect service-to-service communications"
|
|
},
|
|
"behavioral": {
|
|
"types": ["traffic_anomaly", "dns_anomaly", "process_anomaly", "error_anomaly"],
|
|
"icon": "AlertOutlined",
|
|
"color": "#d4756a",
|
|
"description": "Behavioral anomalies detected through eBPF monitoring"
|
|
}
|
|
}
|
|
|
|
for category, info in categories.items():
|
|
if change_type in info["types"]:
|
|
return {
|
|
"name": category,
|
|
"icon": info["icon"],
|
|
"color": info["color"],
|
|
"description": info["description"]
|
|
}
|
|
|
|
return {
|
|
"name": "other",
|
|
"icon": "QuestionCircleOutlined",
|
|
"color": "#8c8c8c",
|
|
"description": "Other changes"
|
|
}
|
|
|
|
|
|
def _get_recommended_actions(change_type: str, risk_level: str) -> list:
|
|
"""Get recommended actions based on change type and risk"""
|
|
actions = []
|
|
|
|
# Type-specific recommendations
|
|
type_actions = {
|
|
"replica_changed": [
|
|
{"action": "Verify pod health", "priority": "high", "automated": True},
|
|
{"action": "Check resource utilization", "priority": "medium", "automated": True},
|
|
],
|
|
"config_changed": [
|
|
{"action": "Review configuration diff", "priority": "high", "automated": False},
|
|
{"action": "Validate application behavior", "priority": "high", "automated": False},
|
|
],
|
|
"image_changed": [
|
|
{"action": "Verify image version compatibility", "priority": "high", "automated": False},
|
|
{"action": "Check for vulnerability reports", "priority": "medium", "automated": True},
|
|
],
|
|
"resource_changed": [
|
|
{"action": "Monitor resource utilization post-change", "priority": "medium", "automated": True},
|
|
{"action": "Verify no OOMKill events", "priority": "high", "automated": True},
|
|
],
|
|
"env_changed": [
|
|
{"action": "Validate environment configuration", "priority": "high", "automated": False},
|
|
{"action": "Check for secret exposure", "priority": "critical", "automated": False},
|
|
],
|
|
"service_port_changed": [
|
|
{"action": "Verify client connectivity", "priority": "high", "automated": True},
|
|
{"action": "Update dependent configurations", "priority": "high", "automated": False},
|
|
],
|
|
"service_selector_changed": [
|
|
{"action": "Verify pod selection is correct", "priority": "critical", "automated": False},
|
|
{"action": "Check for traffic routing issues", "priority": "high", "automated": True},
|
|
],
|
|
"service_removed": [
|
|
{"action": "Verify removal is intentional", "priority": "critical", "automated": False},
|
|
{"action": "Check for dependent services", "priority": "high", "automated": True},
|
|
],
|
|
"network_policy_changed": [
|
|
{"action": "Verify network connectivity", "priority": "high", "automated": True},
|
|
{"action": "Review policy rules", "priority": "high", "automated": False},
|
|
],
|
|
"network_policy_removed": [
|
|
{"action": "Assess security implications", "priority": "critical", "automated": False},
|
|
{"action": "Check for unauthorized access", "priority": "high", "automated": True},
|
|
],
|
|
"ingress_changed": [
|
|
{"action": "Verify external access", "priority": "high", "automated": True},
|
|
{"action": "Check TLS certificate validity", "priority": "medium", "automated": True},
|
|
],
|
|
"route_changed": [
|
|
{"action": "Verify route accessibility", "priority": "high", "automated": True},
|
|
{"action": "Check TLS configuration", "priority": "medium", "automated": True},
|
|
],
|
|
"connection_added": [
|
|
{"action": "Verify connection is expected", "priority": "medium", "automated": False},
|
|
{"action": "Check network policies", "priority": "medium", "automated": True},
|
|
],
|
|
"connection_removed": [
|
|
{"action": "Verify removal is intentional", "priority": "high", "automated": False},
|
|
{"action": "Check for service disruption", "priority": "high", "automated": True},
|
|
],
|
|
"dns_anomaly": [
|
|
{"action": "Review DNS query patterns", "priority": "medium", "automated": False},
|
|
{"action": "Check for unauthorized domains", "priority": "high", "automated": True},
|
|
],
|
|
"process_anomaly": [
|
|
{"action": "Review process execution context", "priority": "high", "automated": False},
|
|
{"action": "Check for security implications", "priority": "critical", "automated": False},
|
|
],
|
|
"traffic_anomaly": [
|
|
{"action": "Analyze traffic patterns", "priority": "medium", "automated": True},
|
|
{"action": "Check for DDoS indicators", "priority": "high", "automated": True},
|
|
],
|
|
"error_anomaly": [
|
|
{"action": "Review error logs", "priority": "high", "automated": False},
|
|
{"action": "Check service health", "priority": "high", "automated": True},
|
|
],
|
|
"spec_changed": [
|
|
{"action": "Review pod spec diff", "priority": "medium", "automated": False},
|
|
{"action": "Verify rollout status", "priority": "medium", "automated": True},
|
|
],
|
|
"label_changed": [
|
|
{"action": "Check label selector impact on services", "priority": "medium", "automated": True},
|
|
{"action": "Verify network policy selectors", "priority": "medium", "automated": False},
|
|
],
|
|
"service_added": [
|
|
{"action": "Verify service endpoint readiness", "priority": "medium", "automated": True},
|
|
],
|
|
"service_type_changed": [
|
|
{"action": "Verify external access implications", "priority": "high", "automated": False},
|
|
{"action": "Check load balancer provisioning", "priority": "medium", "automated": True},
|
|
],
|
|
"network_policy_added": [
|
|
{"action": "Verify policy does not block required traffic", "priority": "high", "automated": True},
|
|
],
|
|
"ingress_added": [
|
|
{"action": "Verify external DNS and TLS configuration", "priority": "medium", "automated": True},
|
|
],
|
|
"ingress_removed": [
|
|
{"action": "Verify external access is no longer needed", "priority": "high", "automated": False},
|
|
],
|
|
"route_added": [
|
|
{"action": "Verify route DNS and TLS configuration", "priority": "medium", "automated": True},
|
|
],
|
|
"route_removed": [
|
|
{"action": "Verify route removal is intentional", "priority": "high", "automated": False},
|
|
],
|
|
"workload_added": [
|
|
{"action": "Verify resource allocation and limits", "priority": "medium", "automated": True},
|
|
],
|
|
"workload_removed": [
|
|
{"action": "Verify removal is intentional", "priority": "high", "automated": False},
|
|
{"action": "Check for dependent services", "priority": "high", "automated": True},
|
|
],
|
|
"port_changed": [
|
|
{"action": "Verify service port mapping consistency", "priority": "high", "automated": True},
|
|
{"action": "Check client connectivity", "priority": "high", "automated": False},
|
|
],
|
|
"namespace_changed": [
|
|
{"action": "Review namespace configuration changes", "priority": "medium", "automated": False},
|
|
],
|
|
}
|
|
|
|
actions = type_actions.get(change_type, [
|
|
{"action": "Review change details", "priority": "medium", "automated": False}
|
|
])
|
|
|
|
# Add risk-based actions
|
|
if risk_level == "critical":
|
|
actions.insert(0, {"action": "Immediate investigation required", "priority": "critical", "automated": False})
|
|
elif risk_level == "high":
|
|
actions.insert(0, {"action": "Prioritize review", "priority": "high", "automated": False})
|
|
|
|
return actions
|
|
|
|
|
|
def calculate_comparison(changes: List[Change]) -> SnapshotComparison:
|
|
"""Calculate before/after comparison from changes"""
|
|
|
|
# Baseline counts (mock)
|
|
before = {
|
|
"workloads": 45,
|
|
"connections": 128,
|
|
"namespaces": 8,
|
|
}
|
|
|
|
# Calculate deltas
|
|
workload_delta = 0
|
|
connection_delta = 0
|
|
|
|
_added_types = {
|
|
ChangeType.WORKLOAD_ADDED, ChangeType.SERVICE_ADDED,
|
|
ChangeType.NETWORK_POLICY_ADDED, ChangeType.INGRESS_ADDED,
|
|
ChangeType.ROUTE_ADDED, ChangeType.CONNECTION_ADDED,
|
|
}
|
|
_removed_types = {
|
|
ChangeType.WORKLOAD_REMOVED, ChangeType.SERVICE_REMOVED,
|
|
ChangeType.NETWORK_POLICY_REMOVED, ChangeType.INGRESS_REMOVED,
|
|
ChangeType.ROUTE_REMOVED, ChangeType.CONNECTION_REMOVED,
|
|
}
|
|
_modified_types = {
|
|
ChangeType.REPLICA_CHANGED, ChangeType.CONFIG_CHANGED,
|
|
ChangeType.IMAGE_CHANGED, ChangeType.LABEL_CHANGED,
|
|
ChangeType.RESOURCE_CHANGED, ChangeType.ENV_CHANGED,
|
|
ChangeType.SPEC_CHANGED, ChangeType.PORT_CHANGED,
|
|
ChangeType.SERVICE_PORT_CHANGED, ChangeType.SERVICE_SELECTOR_CHANGED,
|
|
ChangeType.SERVICE_TYPE_CHANGED, ChangeType.NETWORK_POLICY_CHANGED,
|
|
ChangeType.INGRESS_CHANGED, ChangeType.ROUTE_CHANGED,
|
|
ChangeType.NAMESPACE_CHANGED,
|
|
}
|
|
_anomaly_types = {
|
|
ChangeType.TRAFFIC_ANOMALY, ChangeType.DNS_ANOMALY,
|
|
ChangeType.PROCESS_ANOMALY, ChangeType.ERROR_ANOMALY,
|
|
}
|
|
|
|
for change in changes:
|
|
if change.change_type == ChangeType.WORKLOAD_ADDED:
|
|
workload_delta += 1
|
|
elif change.change_type == ChangeType.WORKLOAD_REMOVED:
|
|
workload_delta -= 1
|
|
elif change.change_type == ChangeType.CONNECTION_ADDED:
|
|
connection_delta += 1
|
|
elif change.change_type == ChangeType.CONNECTION_REMOVED:
|
|
connection_delta -= 1
|
|
|
|
after = {
|
|
"workloads": before["workloads"] + workload_delta,
|
|
"connections": before["connections"] + connection_delta,
|
|
"namespaces": before["namespaces"],
|
|
}
|
|
|
|
# Summary
|
|
added = sum(1 for c in changes if c.change_type in _added_types)
|
|
removed = sum(1 for c in changes if c.change_type in _removed_types)
|
|
modified = sum(1 for c in changes if c.change_type in _modified_types)
|
|
anomalies = sum(1 for c in changes if c.change_type in _anomaly_types)
|
|
|
|
return SnapshotComparison(
|
|
before=before,
|
|
after=after,
|
|
summary={
|
|
"added": added,
|
|
"removed": removed,
|
|
"modified": modified + anomalies,
|
|
}
|
|
)
|
|
|
|
|
|
# ============ Endpoints ============
|
|
|
|
@router.get("/changes", response_model=ChangesResponse)
|
|
async def get_changes(
|
|
cluster_id: Optional[int] = Query(None, description="Cluster ID (optional for multi-cluster via analysis_id)"),
|
|
analysis_id: Optional[int] = Query(None, description="Analysis ID"),
|
|
run_id: Optional[int] = Query(None, description="Filter by specific run ID (requires RUN_BASED_FILTERING feature)"),
|
|
run_ids: Optional[str] = Query(None, description="Comma-separated run IDs to filter by"),
|
|
start_time: Optional[datetime] = Query(None, description="Start of time range"),
|
|
end_time: Optional[datetime] = Query(None, description="End of time range"),
|
|
change_types: Optional[str] = Query(None, description="Comma-separated change types"),
|
|
risk_levels: Optional[str] = Query(None, description="Comma-separated risk levels"),
|
|
limit: int = Query(100, ge=1, le=500, description="Maximum number of changes"),
|
|
offset: int = Query(0, ge=0, description="Offset for pagination"),
|
|
current_user: dict = Depends(get_current_user),
|
|
change_service: ChangeDetectionService = Depends(get_change_detection_service)
|
|
):
|
|
"""
|
|
Get infrastructure changes for a cluster.
|
|
|
|
Returns a list of detected changes including:
|
|
- Workload additions/removals
|
|
- Connection changes
|
|
- Configuration updates
|
|
- Port changes
|
|
|
|
Each change includes:
|
|
- Timestamp
|
|
- Change type
|
|
- Risk assessment
|
|
- Affected services count
|
|
|
|
NOTE: Change events stored ONLY in ClickHouse. PostgreSQL change_events table removed.
|
|
"""
|
|
logger.info(
|
|
"Getting changes from ClickHouse",
|
|
cluster_id=cluster_id,
|
|
analysis_id=analysis_id,
|
|
start_time=start_time,
|
|
end_time=end_time
|
|
)
|
|
|
|
# Parse filter parameters
|
|
type_filter = change_types.split(",") if change_types else None
|
|
risk_filter = risk_levels.split(",") if risk_levels else None
|
|
|
|
# Parse run_ids if provided
|
|
run_id_list = None
|
|
if run_id:
|
|
run_id_list = [run_id]
|
|
elif run_ids and RUN_BASED_FILTERING:
|
|
try:
|
|
run_id_list = [int(r.strip()) for r in run_ids.split(",")]
|
|
except ValueError:
|
|
pass
|
|
|
|
# Require either cluster_id OR analysis_id. If both are missing the
|
|
# query would scan every cluster's history, so refuse it cleanly.
|
|
# Multi-cluster analyses (analysis_id only, no cluster_id) are now
|
|
# supported: the helper rewrites the WHERE clause to cover every
|
|
# `<analysis_id>-<cluster_id>` row.
|
|
if not cluster_id and not analysis_id:
|
|
logger.warning(
|
|
"Neither cluster_id nor analysis_id provided, returning empty result"
|
|
)
|
|
return ChangesResponse(
|
|
changes=[],
|
|
total=0,
|
|
stats=ChangeStats(
|
|
total_changes=0,
|
|
by_type={},
|
|
by_risk={},
|
|
by_namespace={}
|
|
),
|
|
comparison=SnapshotComparison(
|
|
before={"workloads": 0, "connections": 0, "namespaces": 0},
|
|
after={"workloads": 0, "connections": 0, "namespaces": 0},
|
|
summary={"added": 0, "removed": 0, "changed": 0}
|
|
)
|
|
)
|
|
|
|
# Query ClickHouse (ONLY storage for change events)
|
|
try:
|
|
result = await get_changes_from_clickhouse(
|
|
cluster_id=cluster_id,
|
|
analysis_id=analysis_id,
|
|
run_ids=run_id_list,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
change_types=type_filter,
|
|
risk_levels=risk_filter,
|
|
limit=limit,
|
|
offset=offset
|
|
)
|
|
logger.info(
|
|
"ClickHouse data returned",
|
|
total_changes=result.total,
|
|
changes_count=len(result.changes)
|
|
)
|
|
return result
|
|
except Exception as e:
|
|
logger.error(
|
|
"ClickHouse query failed",
|
|
cluster_id=cluster_id,
|
|
error=str(e)
|
|
)
|
|
# Return empty result on error (no fallback)
|
|
return ChangesResponse(
|
|
changes=[],
|
|
total=0,
|
|
stats=ChangeStats(
|
|
total_changes=0,
|
|
by_type={},
|
|
by_risk={},
|
|
by_namespace={}
|
|
),
|
|
comparison=SnapshotComparison(
|
|
before={"workloads": 0, "connections": 0, "namespaces": 0},
|
|
after={"workloads": 0, "connections": 0, "namespaces": 0},
|
|
summary={"added": 0, "removed": 0, "changed": 0}
|
|
)
|
|
)
|
|
|
|
# NOTE: Mock data path removed. All data comes from ClickHouse.
|
|
|
|
|
|
async def get_changes_real_data(
|
|
change_service: ChangeDetectionService,
|
|
cluster_id: int,
|
|
analysis_id: Optional[int],
|
|
start_time: Optional[datetime],
|
|
end_time: Optional[datetime],
|
|
change_types: Optional[List[str]],
|
|
risk_levels: Optional[List[str]],
|
|
limit: int,
|
|
offset: int
|
|
) -> ChangesResponse:
|
|
"""
|
|
Get changes from real database data
|
|
|
|
Queries PostgreSQL change_events table for recorded changes.
|
|
"""
|
|
# Fetch changes from database
|
|
changes_data, total = await change_service.get_changes_from_database(
|
|
cluster_id=cluster_id,
|
|
analysis_id=analysis_id,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
change_types=change_types,
|
|
risk_levels=risk_levels,
|
|
limit=limit,
|
|
offset=offset
|
|
)
|
|
|
|
# Convert to response model
|
|
changes = []
|
|
for data in changes_data:
|
|
try:
|
|
change = Change(
|
|
id=data["id"],
|
|
timestamp=data["timestamp"],
|
|
change_type=ChangeType(data["change_type"]),
|
|
target=data["target"],
|
|
namespace=data.get("namespace", "unknown"),
|
|
details=data.get("details", ""),
|
|
risk=RiskLevel(data.get("risk", "medium")),
|
|
affected_services=data.get("affected_services", 0),
|
|
changed_by=data.get("changed_by", "auto-discovery"),
|
|
metadata=data.get("metadata")
|
|
)
|
|
changes.append(change)
|
|
except Exception as e:
|
|
logger.warning("Failed to parse change record", error=str(e), data=data)
|
|
|
|
# Get stats from database
|
|
stats_data = await change_service.get_change_stats(
|
|
cluster_id=cluster_id,
|
|
analysis_id=analysis_id,
|
|
start_time=start_time,
|
|
end_time=end_time
|
|
)
|
|
|
|
stats = ChangeStats(
|
|
total_changes=stats_data.get("total_changes", 0),
|
|
by_type=stats_data.get("by_type", {}),
|
|
by_risk=stats_data.get("by_risk", {}),
|
|
by_namespace=stats_data.get("by_namespace", {})
|
|
)
|
|
|
|
# Get comparison from database
|
|
comparison_data = await change_service.get_snapshot_comparison(
|
|
cluster_id=cluster_id,
|
|
analysis_id=analysis_id,
|
|
start_time=start_time,
|
|
end_time=end_time
|
|
)
|
|
|
|
comparison = SnapshotComparison(
|
|
before=comparison_data.get("before", {}),
|
|
after=comparison_data.get("after", {}),
|
|
summary=comparison_data.get("summary", {})
|
|
)
|
|
|
|
logger.info(
|
|
"Real data changes retrieved",
|
|
cluster_id=cluster_id,
|
|
total=total,
|
|
returned=len(changes)
|
|
)
|
|
|
|
return ChangesResponse(
|
|
changes=changes,
|
|
total=total,
|
|
stats=stats,
|
|
comparison=comparison,
|
|
)
|
|
|
|
|
|
async def _get_comparison_data(cluster_id: int, analysis_id: Optional[int] = None) -> dict:
|
|
"""Get workload/connection/namespace counts for comparison cards.
|
|
Uses Neo4j for analysis-specific data when analysis_id is provided.
|
|
"""
|
|
from database.postgresql import database
|
|
from database.neo4j import neo4j_service
|
|
|
|
try:
|
|
# When analysis_id is provided, use Neo4j for analysis-specific counts
|
|
if analysis_id:
|
|
try:
|
|
neo4j_workloads = neo4j_service.get_workloads(cluster_id=cluster_id, analysis_id=analysis_id)
|
|
neo4j_communications = neo4j_service.get_communications(cluster_id=cluster_id, analysis_id=analysis_id)
|
|
|
|
# Always use Neo4j results when analysis_id is provided (even if empty)
|
|
# This ensures users see analysis-specific data, not cluster-wide fallback
|
|
workload_count = len(neo4j_workloads) if neo4j_workloads is not None else 0
|
|
connection_count = len(neo4j_communications) if neo4j_communications is not None else 0
|
|
|
|
# Get unique namespaces from workloads
|
|
namespaces = set()
|
|
if neo4j_workloads:
|
|
for w in neo4j_workloads:
|
|
ns = w.get('namespace')
|
|
if ns:
|
|
namespaces.add(ns)
|
|
namespace_count = len(namespaces)
|
|
|
|
logger.debug(
|
|
"Got analysis-specific counts from Neo4j",
|
|
analysis_id=analysis_id,
|
|
workloads=workload_count,
|
|
connections=connection_count,
|
|
namespaces=namespace_count
|
|
)
|
|
|
|
return {
|
|
"before": {
|
|
"workloads": workload_count,
|
|
"connections": connection_count,
|
|
"namespaces": namespace_count
|
|
},
|
|
"after": {
|
|
"workloads": workload_count,
|
|
"connections": connection_count,
|
|
"namespaces": namespace_count
|
|
},
|
|
"added": 0,
|
|
"removed": 0,
|
|
"modified": 0
|
|
}
|
|
except Exception as neo4j_err:
|
|
logger.warning("Neo4j query failed, falling back to PostgreSQL", error=str(neo4j_err))
|
|
|
|
# Fallback: cluster-wide counts from PostgreSQL
|
|
workload_result = await database.fetch_one(
|
|
"SELECT COUNT(*) as count FROM workloads WHERE cluster_id = :cluster_id AND is_active = true",
|
|
{"cluster_id": cluster_id}
|
|
)
|
|
workload_count = workload_result["count"] if workload_result else 0
|
|
|
|
# Connection count
|
|
connection_result = await database.fetch_one(
|
|
"SELECT COUNT(*) as count FROM communications WHERE cluster_id = :cluster_id AND is_active = true",
|
|
{"cluster_id": cluster_id}
|
|
)
|
|
connection_count = connection_result["count"] if connection_result else 0
|
|
|
|
# Namespace count
|
|
namespace_result = await database.fetch_one(
|
|
"SELECT COUNT(DISTINCT name) as count FROM namespaces WHERE cluster_id = :cluster_id",
|
|
{"cluster_id": cluster_id}
|
|
)
|
|
namespace_count = namespace_result["count"] if namespace_result else 0
|
|
|
|
return {
|
|
"before": {
|
|
"workloads": workload_count,
|
|
"connections": connection_count,
|
|
"namespaces": namespace_count
|
|
},
|
|
"after": {
|
|
"workloads": workload_count,
|
|
"connections": connection_count,
|
|
"namespaces": namespace_count
|
|
},
|
|
"added": 0,
|
|
"removed": 0,
|
|
"modified": 0
|
|
}
|
|
except Exception as e:
|
|
logger.warning("Comparison data fetch failed", error=str(e))
|
|
return {"before": {}, "after": {}}
|
|
|
|
|
|
async def get_changes_from_clickhouse(
|
|
cluster_id: Optional[int],
|
|
analysis_id: Optional[int],
|
|
run_ids: Optional[List[int]],
|
|
start_time: Optional[datetime],
|
|
end_time: Optional[datetime],
|
|
change_types: Optional[List[str]],
|
|
risk_levels: Optional[List[str]],
|
|
limit: int,
|
|
offset: int
|
|
) -> ChangesResponse:
|
|
"""
|
|
Get changes from ClickHouse (ONLY storage for change events)
|
|
|
|
This provides better performance for large datasets and run-based filtering.
|
|
PostgreSQL change_events table has been removed.
|
|
|
|
Uses the ClickHouseService from database.clickhouse module.
|
|
|
|
Multi-cluster note: when ``cluster_id`` is None and ``analysis_id`` is
|
|
provided, the analysis_id LIKE '<id>-%' pattern covers every cluster
|
|
that participated in the analysis (worker stores per-cluster suffix).
|
|
Without this branch the endpoint silently returned 0 changes for
|
|
multi-cluster analyses.
|
|
"""
|
|
from database.clickhouse import get_clickhouse_client
|
|
|
|
try:
|
|
client = get_clickhouse_client()
|
|
|
|
# Build WHERE clauses:
|
|
# - base_where: cluster/analysis/time scoping (used for stats - always unfiltered)
|
|
# - filtered_where: base + change_type/risk_level filters (used for paginated results)
|
|
base_parts: List[str] = []
|
|
if cluster_id is not None:
|
|
base_parts.append(f"cluster_id = {int(cluster_id)}")
|
|
|
|
if analysis_id:
|
|
if cluster_id is not None:
|
|
# Single-cluster path: exact match on '46-15' style id.
|
|
ch_aid = await _get_ch_analysis_id(analysis_id, cluster_id)
|
|
base_parts.append(f"analysis_id = '{ch_aid}'")
|
|
else:
|
|
# Multi-cluster path: cover every cluster's per-suffix id
|
|
# ('46-15', '46-16', ...) plus the bare form ('46') for
|
|
# legacy rows written before the suffix convention landed.
|
|
aid_int = int(analysis_id)
|
|
base_parts.append(
|
|
f"(analysis_id = '{aid_int}' "
|
|
f"OR analysis_id LIKE '{aid_int}-%')"
|
|
)
|
|
|
|
# Defensive: if neither scope was supplied we'd return *all*
|
|
# change_events rows from every analysis. Refuse to do that and
|
|
# mirror the original empty-response behaviour to keep the API
|
|
# contract intact.
|
|
if not base_parts:
|
|
return ChangesResponse(
|
|
changes=[],
|
|
total=0,
|
|
stats=ChangeStats(
|
|
total_changes=0,
|
|
by_type={},
|
|
by_risk={},
|
|
by_namespace={},
|
|
),
|
|
comparison=SnapshotComparison(
|
|
before={"workloads": 0, "connections": 0, "namespaces": 0},
|
|
after={"workloads": 0, "connections": 0, "namespaces": 0},
|
|
summary={"added": 0, "removed": 0, "changed": 0},
|
|
),
|
|
)
|
|
|
|
if run_ids:
|
|
run_ids_str = ",".join(str(r) for r in run_ids)
|
|
base_parts.append(f"run_id IN ({run_ids_str})")
|
|
|
|
if start_time:
|
|
base_parts.append(f"timestamp >= '{start_time.isoformat()}'")
|
|
|
|
if end_time:
|
|
base_parts.append(f"timestamp <= '{end_time.isoformat()}'")
|
|
|
|
base_where = " AND ".join(base_parts)
|
|
|
|
filter_parts = list(base_parts)
|
|
if change_types:
|
|
valid_types = {ct.value for ct in ChangeType}
|
|
safe_types = [t for t in change_types if t in valid_types]
|
|
if safe_types:
|
|
types_str = ",".join(f"'{t}'" for t in safe_types)
|
|
filter_parts.append(f"change_type IN ({types_str})")
|
|
|
|
if risk_levels:
|
|
valid_risks = {rl.value for rl in RiskLevel}
|
|
safe_risks = [r for r in risk_levels if r in valid_risks]
|
|
if safe_risks:
|
|
risks_str = ",".join(f"'{r}'" for r in safe_risks)
|
|
filter_parts.append(f"risk_level IN ({risks_str})")
|
|
|
|
where_clause = " AND ".join(filter_parts)
|
|
|
|
# Query changes
|
|
query = f"""
|
|
SELECT
|
|
event_id,
|
|
timestamp,
|
|
detected_at,
|
|
change_type,
|
|
risk_level,
|
|
target_name,
|
|
target_namespace,
|
|
affected_services,
|
|
changed_by,
|
|
details,
|
|
metadata,
|
|
run_id,
|
|
run_number
|
|
FROM change_events
|
|
WHERE {where_clause}
|
|
ORDER BY timestamp DESC
|
|
LIMIT {limit} OFFSET {offset}
|
|
"""
|
|
|
|
rows = client.execute(query)
|
|
|
|
# Query total count
|
|
count_query = f"SELECT count() FROM change_events WHERE {where_clause}"
|
|
total = client.execute(count_query)[0][0]
|
|
|
|
# Debug logging - critical for diagnosing data issues
|
|
logger.info(
|
|
"ClickHouse query results",
|
|
where_clause=where_clause,
|
|
rows_returned=len(rows),
|
|
total_count=total,
|
|
limit=limit,
|
|
offset=offset
|
|
)
|
|
|
|
# Log sample row structure if data exists but might have parse issues
|
|
if rows and len(rows) > 0 and total > 0:
|
|
sample_row = rows[0]
|
|
logger.debug(
|
|
"Sample row structure",
|
|
row_length=len(sample_row) if sample_row else 0,
|
|
change_type_value=sample_row[3] if len(sample_row) > 3 else None,
|
|
risk_level_value=sample_row[4] if len(sample_row) > 4 else None
|
|
)
|
|
|
|
# Convert to response model
|
|
changes = []
|
|
parse_errors = 0
|
|
for idx, row in enumerate(rows):
|
|
try:
|
|
# Use actual event_id (UUID) as the change ID for detail lookups
|
|
event_id = str(row[0]) if row[0] else str(idx + offset + 1)
|
|
|
|
# Safely parse change_type - handle unknown types gracefully
|
|
raw_change_type = row[3] if len(row) > 3 else None
|
|
valid_change_types = [e.value for e in ChangeType]
|
|
if raw_change_type in valid_change_types:
|
|
change_type = ChangeType(raw_change_type)
|
|
else:
|
|
# Map unknown types or skip
|
|
logger.debug(f"Unknown change_type: {raw_change_type}, mapping to CONFIG_CHANGED")
|
|
change_type = ChangeType.CONFIG_CHANGED
|
|
|
|
# Safely parse risk_level
|
|
raw_risk_level = row[4] if len(row) > 4 else None
|
|
valid_risk_levels = [e.value for e in RiskLevel]
|
|
if raw_risk_level in valid_risk_levels:
|
|
risk_level = RiskLevel(raw_risk_level)
|
|
else:
|
|
risk_level = RiskLevel.MEDIUM
|
|
|
|
change = Change(
|
|
id=event_id, # Use real event_id (UUID) for proper detail lookups
|
|
timestamp=row[1] if row[1] else row[2], # timestamp or detected_at
|
|
change_type=change_type,
|
|
target=row[5] or "unknown",
|
|
namespace=row[6] or "unknown",
|
|
details=row[9] or "",
|
|
risk=risk_level,
|
|
affected_services=row[7] or 0,
|
|
changed_by=row[8] or "auto-discovery",
|
|
metadata={"run_id": row[11] if len(row) > 11 else None, "run_number": row[12] if len(row) > 12 else None}
|
|
)
|
|
changes.append(change)
|
|
except Exception as e:
|
|
parse_errors += 1
|
|
if parse_errors <= 5: # Only log first 5 errors to avoid spam
|
|
logger.warning(
|
|
"Failed to parse ClickHouse change record",
|
|
error=str(e),
|
|
row_index=idx,
|
|
row_data=str(row)[:500] if row else None # Truncate for logging
|
|
)
|
|
|
|
# Log parse summary
|
|
if parse_errors > 0:
|
|
logger.warning(
|
|
"Change record parse errors",
|
|
total_rows=len(rows),
|
|
successful_parses=len(changes),
|
|
parse_errors=parse_errors
|
|
)
|
|
|
|
# Query stats from UNFILTERED data (base_where) so summary cards
|
|
# always reflect the full analysis picture regardless of type/risk filters.
|
|
stats_query = f"""
|
|
SELECT
|
|
change_type,
|
|
risk_level,
|
|
count() as cnt
|
|
FROM change_events
|
|
WHERE {base_where}
|
|
GROUP BY change_type, risk_level
|
|
"""
|
|
|
|
try:
|
|
stats_rows = client.execute(stats_query)
|
|
by_type = {}
|
|
by_risk = {}
|
|
for stat_row in stats_rows:
|
|
by_type[stat_row[0]] = by_type.get(stat_row[0], 0) + stat_row[2]
|
|
by_risk[stat_row[1]] = by_risk.get(stat_row[1], 0) + stat_row[2]
|
|
except Exception as e:
|
|
logger.warning("Stats query failed, using empty stats", error=str(e))
|
|
by_type = {}
|
|
by_risk = {}
|
|
|
|
by_namespace = {}
|
|
try:
|
|
ns_query = f"""
|
|
SELECT target_namespace, count() as cnt
|
|
FROM change_events
|
|
WHERE {base_where}
|
|
GROUP BY target_namespace
|
|
"""
|
|
ns_rows = client.execute(ns_query)
|
|
by_namespace = {str(r[0]): r[1] for r in ns_rows if r[0]}
|
|
except Exception as e:
|
|
logger.warning("Namespace stats query failed", error=str(e))
|
|
|
|
stats = ChangeStats(
|
|
total_changes=total,
|
|
by_type=by_type,
|
|
by_risk=by_risk,
|
|
by_namespace=by_namespace
|
|
)
|
|
|
|
# Get comparison data - uses Neo4j for analysis-specific counts when analysis_id is provided
|
|
comparison_data = await _get_comparison_data(cluster_id, analysis_id)
|
|
|
|
comparison = SnapshotComparison(
|
|
before=comparison_data.get("before", {}),
|
|
after=comparison_data.get("after", {}),
|
|
summary={
|
|
"total_changes": total,
|
|
"added": comparison_data.get("added", 0),
|
|
"removed": comparison_data.get("removed", 0),
|
|
"modified": comparison_data.get("modified", 0)
|
|
}
|
|
)
|
|
|
|
logger.info(
|
|
"ClickHouse changes retrieved",
|
|
cluster_id=cluster_id,
|
|
total=total,
|
|
returned=len(changes)
|
|
)
|
|
|
|
return ChangesResponse(
|
|
changes=changes,
|
|
total=total,
|
|
stats=stats,
|
|
comparison=comparison
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error("ClickHouse query failed", error=str(e))
|
|
raise
|
|
|
|
|
|
@router.get("/changes/{change_id}")
|
|
async def get_change_details(
|
|
change_id: str,
|
|
current_user: dict = Depends(get_current_user),
|
|
change_service: ChangeDetectionService = Depends(get_change_detection_service)
|
|
):
|
|
"""Get detailed information about a specific change from ClickHouse"""
|
|
_validate_change_id(change_id)
|
|
|
|
try:
|
|
from database.clickhouse import get_clickhouse_client
|
|
|
|
client = get_clickhouse_client()
|
|
|
|
# Query with all available columns for rich detail view
|
|
query = """
|
|
SELECT
|
|
event_id,
|
|
cluster_id,
|
|
cluster_name,
|
|
analysis_id,
|
|
detected_at,
|
|
timestamp,
|
|
change_type,
|
|
target_name,
|
|
target_namespace,
|
|
target_type,
|
|
details,
|
|
risk_level,
|
|
affected_services,
|
|
blast_radius,
|
|
changed_by,
|
|
before_state,
|
|
after_state,
|
|
metadata,
|
|
run_id,
|
|
run_number
|
|
FROM change_events
|
|
WHERE event_id = %(change_id)s
|
|
LIMIT 1
|
|
"""
|
|
|
|
result = client.execute(query, {"change_id": change_id})
|
|
|
|
if result:
|
|
row = result[0]
|
|
# Parse metadata JSON if present
|
|
metadata = {}
|
|
if row[17]:
|
|
try:
|
|
import json
|
|
metadata = json.loads(row[17]) if isinstance(row[17], str) else row[17]
|
|
except:
|
|
metadata = {}
|
|
|
|
# Parse before/after states
|
|
before_state = None
|
|
after_state = None
|
|
try:
|
|
import json
|
|
if row[15]:
|
|
before_state = json.loads(row[15]) if isinstance(row[15], str) else row[15]
|
|
if row[16]:
|
|
after_state = json.loads(row[16]) if isinstance(row[16], str) else row[16]
|
|
except:
|
|
before_state = row[15]
|
|
after_state = row[16]
|
|
|
|
change_type = row[6]
|
|
|
|
return {
|
|
"id": str(row[0]), # event_id (UUID)
|
|
"cluster_id": row[1],
|
|
"cluster_name": row[2] or "",
|
|
"analysis_id": row[3],
|
|
"detected_at": row[4].isoformat() if row[4] else None,
|
|
"timestamp": row[5].isoformat() if row[5] else None,
|
|
"change_type": change_type,
|
|
"target": row[7] or "unknown",
|
|
"namespace": row[8] or "unknown",
|
|
"target_type": row[9] or "workload",
|
|
"details": row[10] or "",
|
|
"risk": row[11] or "medium",
|
|
"affected_services": row[12] or 0,
|
|
"blast_radius": row[13] or 0,
|
|
"changed_by": row[14] or "auto-discovery",
|
|
"before_state": before_state,
|
|
"after_state": after_state,
|
|
"metadata": metadata,
|
|
"run_id": row[18],
|
|
"run_number": row[19],
|
|
"status": "detected",
|
|
"rollback_available": change_type in [
|
|
ChangeType.CONFIG_CHANGED.value,
|
|
ChangeType.REPLICA_CHANGED.value,
|
|
ChangeType.IMAGE_CHANGED.value
|
|
],
|
|
# Computed fields for UI
|
|
"severity_indicator": _get_severity_indicator(row[11], row[12], row[13]),
|
|
"change_category": _get_change_category(change_type),
|
|
"recommended_actions": _get_recommended_actions(change_type, row[11]),
|
|
"audit_trail": [
|
|
{
|
|
"action": "detected",
|
|
"timestamp": row[4].isoformat() if row[4] else None,
|
|
"actor": row[14] or "auto-discovery",
|
|
"details": f"Change detected during analysis run #{row[19] or 1}"
|
|
}
|
|
]
|
|
}
|
|
else:
|
|
raise HTTPException(status_code=404, detail=f"Change not found: {change_id}")
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.warning("ClickHouse lookup failed", error=str(e), change_id=change_id)
|
|
raise HTTPException(status_code=500, detail=f"Failed to fetch change details: {str(e)}")
|
|
|
|
# Fallback to mock implementation
|
|
changes = generate_mock_changes(cluster_id=1)
|
|
|
|
for change in changes:
|
|
if change.id == change_id:
|
|
return {
|
|
**change.model_dump(),
|
|
"related_changes": [],
|
|
"rollback_available": change.change_type in [
|
|
ChangeType.CONFIG_CHANGED,
|
|
ChangeType.REPLICA_CHANGED
|
|
],
|
|
"audit_trail": [
|
|
{
|
|
"action": "detected",
|
|
"timestamp": change.timestamp.isoformat(),
|
|
"actor": "auto-discovery"
|
|
}
|
|
]
|
|
}
|
|
|
|
raise HTTPException(status_code=404, detail="Change not found")
|
|
|
|
|
|
@router.get("/changes/stats/summary")
|
|
async def get_change_stats_summary(
|
|
cluster_id: Optional[int] = Query(None, description="Cluster ID (optional for multi-cluster via analysis_id)"),
|
|
analysis_id: Optional[int] = Query(None, description="Analysis ID"),
|
|
days: int = Query(7, ge=1, le=90, description="Number of days to include"),
|
|
current_user: dict = Depends(get_current_user),
|
|
change_service: ChangeDetectionService = Depends(get_change_detection_service)
|
|
):
|
|
"""Get summary statistics of changes over time from ClickHouse"""
|
|
|
|
end_time = datetime.utcnow()
|
|
start_time = end_time - timedelta(days=days)
|
|
|
|
if cluster_id:
|
|
try:
|
|
from database.clickhouse import get_clickhouse_client
|
|
|
|
client = get_clickhouse_client()
|
|
|
|
# Build WHERE clause
|
|
where_parts = ["detected_at >= %(start_time)s", "detected_at <= %(end_time)s"]
|
|
params = {"start_time": start_time, "end_time": end_time}
|
|
|
|
if cluster_id:
|
|
where_parts.append("cluster_id = %(cluster_id)s")
|
|
params["cluster_id"] = cluster_id
|
|
if analysis_id:
|
|
ch_aid = await _get_ch_analysis_id(analysis_id, cluster_id)
|
|
where_parts.append("analysis_id = %(analysis_id)s")
|
|
params["analysis_id"] = ch_aid
|
|
|
|
where_clause = " AND ".join(where_parts)
|
|
|
|
# Get total and by_type
|
|
type_query = f"""
|
|
SELECT change_type, count() as cnt
|
|
FROM change_events
|
|
WHERE {where_clause}
|
|
GROUP BY change_type
|
|
"""
|
|
type_results = client.execute(type_query, params)
|
|
by_type = {str(r[0]): r[1] for r in type_results}
|
|
total_changes = sum(by_type.values())
|
|
|
|
# Get by_risk
|
|
risk_query = f"""
|
|
SELECT risk_level, count() as cnt
|
|
FROM change_events
|
|
WHERE {where_clause}
|
|
GROUP BY risk_level
|
|
"""
|
|
risk_results = client.execute(risk_query, params)
|
|
by_risk = {str(r[0]): r[1] for r in risk_results}
|
|
|
|
# Get by_namespace
|
|
ns_query = f"""
|
|
SELECT target_namespace, count() as cnt
|
|
FROM change_events
|
|
WHERE {where_clause}
|
|
GROUP BY target_namespace
|
|
"""
|
|
ns_results = client.execute(ns_query, params)
|
|
by_namespace = {r[0] if r[0] else "unknown": r[1] for r in ns_results}
|
|
|
|
# Get daily breakdown
|
|
daily_query = f"""
|
|
SELECT toDate(detected_at) as day, count() as cnt
|
|
FROM change_events
|
|
WHERE {where_clause}
|
|
GROUP BY day
|
|
ORDER BY day
|
|
"""
|
|
daily_results = client.execute(daily_query, params)
|
|
daily_counts = {str(r[0]): r[1] for r in daily_results}
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"analysis_id": analysis_id,
|
|
"period": {
|
|
"start": start_time.isoformat(),
|
|
"end": end_time.isoformat(),
|
|
"days": days,
|
|
},
|
|
"stats": {
|
|
"total_changes": total_changes,
|
|
"by_type": by_type,
|
|
"by_risk": by_risk,
|
|
"by_namespace": by_namespace
|
|
},
|
|
"daily_breakdown": daily_counts,
|
|
"trends": {
|
|
"avg_changes_per_day": total_changes / days if days > 0 else 0,
|
|
"high_risk_ratio": (
|
|
(by_risk.get("critical", 0) + by_risk.get("high", 0)) /
|
|
total_changes if total_changes > 0 else 0
|
|
),
|
|
},
|
|
"data_source": "clickhouse"
|
|
}
|
|
except Exception as e:
|
|
logger.warning("ClickHouse stats failed", error=str(e))
|
|
|
|
# Fallback to mock data
|
|
changes = generate_mock_changes(
|
|
cluster_id=cluster_id,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
)
|
|
|
|
stats = calculate_stats(changes)
|
|
|
|
# Calculate daily breakdown
|
|
daily_counts = {}
|
|
for change in changes:
|
|
day_key = change.timestamp.strftime("%Y-%m-%d")
|
|
daily_counts[day_key] = daily_counts.get(day_key, 0) + 1
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"analysis_id": analysis_id,
|
|
"period": {
|
|
"start": start_time.isoformat(),
|
|
"end": end_time.isoformat(),
|
|
"days": days,
|
|
},
|
|
"stats": stats.model_dump(),
|
|
"daily_breakdown": daily_counts,
|
|
"trends": {
|
|
"avg_changes_per_day": len(changes) / days if days > 0 else 0,
|
|
"high_risk_ratio": (
|
|
(stats.by_risk.get("critical", 0) + stats.by_risk.get("high", 0)) /
|
|
stats.total_changes if stats.total_changes > 0 else 0
|
|
),
|
|
},
|
|
"data_source": "mock"
|
|
}
|
|
|
|
|
|
@router.get("/changes/compare")
|
|
async def compare_snapshots(
|
|
cluster_id: int = Query(..., description="Cluster ID"),
|
|
analysis_id_before: int = Query(..., description="Earlier analysis ID"),
|
|
analysis_id_after: int = Query(..., description="Later analysis ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
change_service: ChangeDetectionService = Depends(get_change_detection_service)
|
|
):
|
|
"""
|
|
Compare two analysis snapshots to identify differences.
|
|
|
|
Useful for:
|
|
- Before/after deployment comparisons
|
|
- Drift detection
|
|
- Change validation
|
|
|
|
NOTE: This function queries PostgreSQL for workloads/communications
|
|
(these tables remain in PostgreSQL - only change_events moved to ClickHouse)
|
|
"""
|
|
|
|
if True: # Always use real data (PostgreSQL for workloads/communications)
|
|
try:
|
|
from database.postgresql import database
|
|
|
|
# Get workloads for both analyses
|
|
workloads_query = """
|
|
SELECT DISTINCT w.name, n.name as namespace
|
|
FROM workloads w
|
|
JOIN namespaces n ON w.namespace_id = n.id
|
|
JOIN analysis_runs ar ON ar.analysis_id = :analysis_id
|
|
WHERE w.cluster_id = :cluster_id
|
|
AND w.is_active = true
|
|
"""
|
|
|
|
# Get workloads for "before" analysis
|
|
before_workloads = await database.fetch_all(workloads_query, {
|
|
"cluster_id": cluster_id,
|
|
"analysis_id": analysis_id_before
|
|
})
|
|
|
|
# Get workloads for "after" analysis
|
|
after_workloads = await database.fetch_all(workloads_query, {
|
|
"cluster_id": cluster_id,
|
|
"analysis_id": analysis_id_after
|
|
})
|
|
|
|
before_names = set(w["name"] for w in before_workloads)
|
|
after_names = set(w["name"] for w in after_workloads)
|
|
|
|
workloads_added = list(after_names - before_names)
|
|
workloads_removed = list(before_names - after_names)
|
|
|
|
# Get connections count
|
|
connections_query = """
|
|
SELECT COUNT(*) as count
|
|
FROM communications c
|
|
WHERE c.cluster_id = :cluster_id AND c.is_active = true
|
|
"""
|
|
|
|
before_connections = await database.fetch_one(connections_query, {
|
|
"cluster_id": cluster_id
|
|
})
|
|
after_connections = await database.fetch_one(connections_query, {
|
|
"cluster_id": cluster_id
|
|
})
|
|
|
|
# Get namespaces
|
|
namespaces_query = """
|
|
SELECT name FROM namespaces WHERE cluster_id = :cluster_id
|
|
"""
|
|
namespaces = await database.fetch_all(namespaces_query, {"cluster_id": cluster_id})
|
|
namespace_names = [n["name"] for n in namespaces]
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"analysis_before": {
|
|
"id": analysis_id_before,
|
|
"workloads": len(before_workloads),
|
|
"connections": before_connections.get("count", 0) if before_connections else 0,
|
|
"namespaces": namespace_names,
|
|
},
|
|
"analysis_after": {
|
|
"id": analysis_id_after,
|
|
"workloads": len(after_workloads),
|
|
"connections": after_connections.get("count", 0) if after_connections else 0,
|
|
"namespaces": namespace_names,
|
|
},
|
|
"diff": {
|
|
"workloads_added": workloads_added,
|
|
"workloads_removed": workloads_removed,
|
|
"connections_added": [],
|
|
"connections_removed": [],
|
|
"namespaces_added": [],
|
|
"namespaces_removed": [],
|
|
},
|
|
"summary": {
|
|
"total_changes": len(workloads_added) + len(workloads_removed),
|
|
"workload_changes": len(workloads_added) + len(workloads_removed),
|
|
"connection_changes": 0,
|
|
"namespace_changes": 0,
|
|
},
|
|
"data_source": "real"
|
|
}
|
|
except Exception as e:
|
|
logger.warning("Real data comparison failed, falling back to mock", error=str(e))
|
|
|
|
# Mock comparison result (fallback)
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"analysis_before": {
|
|
"id": analysis_id_before,
|
|
"workloads": 45,
|
|
"connections": 128,
|
|
"namespaces": ["default", "production", "staging"],
|
|
},
|
|
"analysis_after": {
|
|
"id": analysis_id_after,
|
|
"workloads": 47,
|
|
"connections": 132,
|
|
"namespaces": ["default", "production", "staging", "monitoring"],
|
|
},
|
|
"diff": {
|
|
"workloads_added": ["payment-service", "notification-service"],
|
|
"workloads_removed": [],
|
|
"connections_added": [
|
|
{"source": "api-gateway", "target": "payment-service", "port": 8080},
|
|
{"source": "notification-service", "target": "kafka-broker", "port": 9092},
|
|
],
|
|
"connections_removed": [],
|
|
"namespaces_added": ["monitoring"],
|
|
"namespaces_removed": [],
|
|
},
|
|
"summary": {
|
|
"total_changes": 6,
|
|
"workload_changes": 2,
|
|
"connection_changes": 4,
|
|
"namespace_changes": 1,
|
|
},
|
|
"data_source": "mock"
|
|
}
|
|
|
|
|
|
# ============ Worker Management Endpoints ============
|
|
|
|
@router.get("/changes/worker/status")
|
|
async def get_worker_status(
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get the status of the change detection background worker.
|
|
|
|
Returns:
|
|
- running: Whether the worker is currently running
|
|
- enabled: Whether the worker is enabled
|
|
- config: Worker configuration (intervals, circuit breaker settings)
|
|
- last_detections: Last detection times per analysis
|
|
- circuits_open: Currently open circuit breakers
|
|
"""
|
|
try:
|
|
from workers.change_detection_worker import change_detection_worker
|
|
return change_detection_worker.get_status()
|
|
except ImportError:
|
|
return {
|
|
"error": "Change detection worker module not available",
|
|
"running": False,
|
|
"enabled": False
|
|
}
|
|
|
|
|
|
@router.get("/changes/diagnose/{analysis_id}")
|
|
async def diagnose_change_detection(
|
|
analysis_id: int,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Diagnose change detection issues for a specific analysis.
|
|
|
|
Checks:
|
|
- Analysis configuration (change detection enabled, strategy)
|
|
- Stored workloads in PostgreSQL for the analysis cluster
|
|
- Worker status and last detection times
|
|
- ClickHouse change_events count
|
|
- K8s connection status
|
|
|
|
Returns diagnostic information to help troubleshoot why changes aren't being detected.
|
|
"""
|
|
from database.postgresql import database
|
|
from database.clickhouse import get_clickhouse_client
|
|
|
|
diagnostics = {
|
|
"analysis_id": analysis_id,
|
|
"checks": {},
|
|
"issues": [],
|
|
"recommendations": []
|
|
}
|
|
|
|
try:
|
|
# Check 1: Get analysis info
|
|
analysis_query = """
|
|
SELECT a.id, a.name, a.status, a.cluster_id, a.namespaces,
|
|
a.change_detection_enabled, a.change_detection_strategy, a.change_detection_types,
|
|
a.started_at, a.stopped_at
|
|
FROM analyses a
|
|
WHERE a.id = :analysis_id
|
|
"""
|
|
analysis = await database.fetch_one(analysis_query, {"analysis_id": analysis_id})
|
|
|
|
if not analysis:
|
|
diagnostics["issues"].append("Analysis not found")
|
|
return diagnostics
|
|
|
|
diagnostics["checks"]["analysis"] = {
|
|
"name": analysis["name"],
|
|
"status": analysis["status"],
|
|
"cluster_id": analysis["cluster_id"],
|
|
"namespaces": analysis["namespaces"],
|
|
"change_detection_enabled": analysis["change_detection_enabled"],
|
|
"change_detection_strategy": analysis["change_detection_strategy"],
|
|
"started_at": str(analysis["started_at"]) if analysis["started_at"] else None,
|
|
"stopped_at": str(analysis["stopped_at"]) if analysis["stopped_at"] else None
|
|
}
|
|
|
|
if not analysis["change_detection_enabled"]:
|
|
diagnostics["issues"].append("Change detection is DISABLED for this analysis")
|
|
diagnostics["recommendations"].append("Enable change detection when creating the analysis")
|
|
|
|
# Check 2: Count stored workloads for the cluster
|
|
cluster_id = analysis["cluster_id"]
|
|
workloads_query = """
|
|
SELECT workload_type, COUNT(*) as count,
|
|
COUNT(*) FILTER (WHERE is_active = true) as active_count
|
|
FROM workloads
|
|
WHERE cluster_id = :cluster_id
|
|
GROUP BY workload_type
|
|
"""
|
|
workloads = await database.fetch_all(workloads_query, {"cluster_id": cluster_id})
|
|
|
|
workload_summary = {}
|
|
total_workloads = 0
|
|
total_active = 0
|
|
for w in workloads:
|
|
workload_summary[w["workload_type"]] = {
|
|
"total": w["count"],
|
|
"active": w["active_count"]
|
|
}
|
|
total_workloads += w["count"]
|
|
total_active += w["active_count"]
|
|
|
|
diagnostics["checks"]["workloads"] = {
|
|
"cluster_id": cluster_id,
|
|
"by_type": workload_summary,
|
|
"total": total_workloads,
|
|
"active": total_active
|
|
}
|
|
|
|
if total_workloads == 0:
|
|
diagnostics["issues"].append("No workloads stored in PostgreSQL for this cluster")
|
|
diagnostics["recommendations"].append("Run workload discovery first - workloads must be stored before changes can be detected")
|
|
elif total_active == 0:
|
|
diagnostics["issues"].append("All workloads are marked as inactive - no changes will be detected")
|
|
|
|
# Check 3: Check if specific namespace workloads exist (if analysis has namespace scope)
|
|
if analysis["namespaces"]:
|
|
import json
|
|
namespaces = json.loads(analysis["namespaces"]) if isinstance(analysis["namespaces"], str) else analysis["namespaces"]
|
|
if namespaces:
|
|
ns_workloads_query = """
|
|
SELECT n.name as namespace, COUNT(w.id) as workload_count
|
|
FROM namespaces n
|
|
LEFT JOIN workloads w ON w.namespace_id = n.id AND w.is_active = true
|
|
WHERE n.cluster_id = :cluster_id AND n.name = ANY(:namespaces)
|
|
GROUP BY n.name
|
|
"""
|
|
ns_workloads = await database.fetch_all(ns_workloads_query, {
|
|
"cluster_id": cluster_id,
|
|
"namespaces": namespaces
|
|
})
|
|
|
|
diagnostics["checks"]["namespace_scope"] = {
|
|
"analysis_namespaces": namespaces,
|
|
"workloads_per_namespace": {row["namespace"]: row["workload_count"] for row in ns_workloads}
|
|
}
|
|
|
|
for ns in namespaces:
|
|
ns_count = next((row["workload_count"] for row in ns_workloads if row["namespace"] == ns), 0)
|
|
if ns_count == 0:
|
|
diagnostics["issues"].append(f"Namespace '{ns}' has no active workloads stored")
|
|
|
|
# Check 4: ClickHouse change_events count
|
|
try:
|
|
ch_client = get_clickhouse_client()
|
|
ch_aid = await _get_ch_analysis_id(analysis_id, cluster_id)
|
|
ch_query = f"""
|
|
SELECT count() as total,
|
|
countIf(change_type = 'replica_changed') as replica_changes,
|
|
min(timestamp) as first_change,
|
|
max(timestamp) as last_change
|
|
FROM change_events
|
|
WHERE analysis_id = '{ch_aid}'
|
|
"""
|
|
ch_result = ch_client.execute(ch_query)
|
|
if ch_result:
|
|
row = ch_result[0]
|
|
diagnostics["checks"]["clickhouse"] = {
|
|
"total_changes": row[0],
|
|
"replica_changes": row[1],
|
|
"first_change": str(row[2]) if row[2] else None,
|
|
"last_change": str(row[3]) if row[3] else None
|
|
}
|
|
|
|
if row[0] == 0:
|
|
diagnostics["issues"].append("No change events recorded in ClickHouse for this analysis")
|
|
diagnostics["recommendations"].append("Check if Change Detection Worker is running and RabbitMQ is operational")
|
|
except Exception as e:
|
|
diagnostics["checks"]["clickhouse"] = {"error": str(e)}
|
|
diagnostics["issues"].append(f"ClickHouse query failed: {str(e)}")
|
|
|
|
# Check 5: Worker status
|
|
try:
|
|
from workers.change_detection_worker import change_detection_worker
|
|
worker_status = change_detection_worker.get_status()
|
|
diagnostics["checks"]["worker"] = worker_status
|
|
|
|
if not worker_status.get("enabled"):
|
|
diagnostics["issues"].append("Change Detection Worker is DISABLED")
|
|
diagnostics["recommendations"].append("Set CHANGE_DETECTION_ENABLED=true environment variable")
|
|
|
|
if not worker_status.get("running"):
|
|
diagnostics["issues"].append("Change Detection Worker is not running")
|
|
diagnostics["recommendations"].append("Deploy the change-detection-worker or enable embedded mode")
|
|
except Exception as e:
|
|
diagnostics["checks"]["worker"] = {"error": str(e), "available": False}
|
|
|
|
# Summary
|
|
diagnostics["summary"] = {
|
|
"total_issues": len(diagnostics["issues"]),
|
|
"healthy": len(diagnostics["issues"]) == 0
|
|
}
|
|
|
|
except Exception as e:
|
|
diagnostics["error"] = str(e)
|
|
|
|
return diagnostics
|
|
|
|
|
|
@router.post("/changes/worker/trigger/{analysis_id}")
|
|
async def trigger_change_detection(
|
|
analysis_id: int,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Manually trigger change detection for a specific analysis.
|
|
|
|
Useful for:
|
|
- On-demand change detection outside regular schedule
|
|
- Testing the change detection pipeline
|
|
- Immediate detection after a known deployment
|
|
|
|
Returns:
|
|
- analysis_id: The analysis that was checked
|
|
- cluster_id: The cluster for the analysis
|
|
- changes_detected: Number of changes found
|
|
- changes: Preview of detected changes (first 10)
|
|
"""
|
|
try:
|
|
from workers.change_detection_worker import change_detection_worker
|
|
result = await change_detection_worker.trigger_detection(analysis_id)
|
|
return result
|
|
except ImportError:
|
|
return {
|
|
"error": "Change detection worker module not available"
|
|
}
|
|
except Exception as e:
|
|
logger.error("Manual change detection failed", analysis_id=analysis_id, error=str(e))
|
|
return {
|
|
"error": str(e),
|
|
"analysis_id": analysis_id
|
|
}
|
|
|
|
|
|
@router.post("/changes/worker/enable")
|
|
async def enable_worker(
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Enable and start the change detection background worker.
|
|
|
|
Note: This requires appropriate permissions (admin or operator role).
|
|
"""
|
|
try:
|
|
from workers.change_detection_worker import change_detection_worker
|
|
result = await change_detection_worker.enable()
|
|
logger.info("Change detection worker enabled", user=current_user.get("username"))
|
|
return result
|
|
except ImportError:
|
|
return {
|
|
"error": "Change detection worker module not available"
|
|
}
|
|
|
|
|
|
@router.post("/changes/worker/disable")
|
|
async def disable_worker(
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Disable and stop the change detection background worker.
|
|
|
|
Note: This requires appropriate permissions (admin or operator role).
|
|
"""
|
|
try:
|
|
from workers.change_detection_worker import change_detection_worker
|
|
result = await change_detection_worker.disable()
|
|
logger.info("Change detection worker disabled", user=current_user.get("username"))
|
|
return result
|
|
except ImportError:
|
|
return {
|
|
"error": "Change detection worker module not available"
|
|
}
|
|
|
|
|
|
# ============ Run-Based Filtering Endpoints (Phase 6) ============
|
|
|
|
@router.get("/changes/runs/{analysis_id}")
|
|
async def get_analysis_runs(
|
|
analysis_id: int,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get all runs for an analysis.
|
|
|
|
Returns list of run information including:
|
|
- run_id: Unique run identifier
|
|
- run_number: Sequential run number (1, 2, 3...)
|
|
- started_at: When the run started
|
|
- completed_at: When the run completed (null if still running)
|
|
- status: running, completed, stopped, failed
|
|
- changes_detected: Number of changes detected in this run
|
|
|
|
Requires RUN_BASED_FILTERING_ENABLED=true for full functionality.
|
|
"""
|
|
from database.postgresql import database
|
|
|
|
query = """
|
|
SELECT
|
|
id as run_id,
|
|
run_number,
|
|
status,
|
|
start_time,
|
|
end_time,
|
|
duration_seconds,
|
|
events_collected,
|
|
workloads_discovered,
|
|
communications_discovered,
|
|
anomalies_detected,
|
|
changes_detected,
|
|
error_message,
|
|
metadata
|
|
FROM analysis_runs
|
|
WHERE analysis_id = :analysis_id
|
|
ORDER BY run_number DESC
|
|
"""
|
|
|
|
try:
|
|
runs = await database.fetch_all(query, {"analysis_id": analysis_id})
|
|
|
|
return {
|
|
"analysis_id": analysis_id,
|
|
"total_runs": len(runs),
|
|
"run_based_filtering_enabled": RUN_BASED_FILTERING,
|
|
"runs": [dict(r) for r in runs]
|
|
}
|
|
except Exception as e:
|
|
logger.error("Failed to fetch analysis runs", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/changes/runs/{analysis_id}/stats")
|
|
async def get_run_stats(
|
|
analysis_id: int,
|
|
run_id: Optional[int] = Query(None, description="Specific run ID"),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get change statistics per run from ClickHouse.
|
|
|
|
Queries the base change_events table directly so all change types
|
|
(including newly added ones) are automatically included.
|
|
"""
|
|
try:
|
|
from database.clickhouse import get_clickhouse_client
|
|
|
|
client = get_clickhouse_client()
|
|
|
|
ch_aid = await _get_ch_analysis_id(analysis_id)
|
|
where_clause = f"analysis_id = '{ch_aid}'"
|
|
if run_id:
|
|
where_clause += f" AND run_id = {run_id}"
|
|
|
|
query = f"""
|
|
SELECT
|
|
run_id,
|
|
max(run_number) AS run_number,
|
|
count() AS total_changes,
|
|
countIf(risk_level = 'critical') AS critical_count,
|
|
countIf(risk_level = 'high') AS high_count,
|
|
countIf(risk_level = 'medium') AS medium_count,
|
|
countIf(risk_level = 'low') AS low_count,
|
|
min(timestamp) AS first_change_at,
|
|
max(timestamp) AS last_change_at
|
|
FROM change_events
|
|
WHERE {where_clause}
|
|
GROUP BY run_id
|
|
ORDER BY run_id DESC
|
|
"""
|
|
|
|
rows = client.execute(query)
|
|
|
|
# Fetch per-type breakdown for each run
|
|
type_query = f"""
|
|
SELECT
|
|
run_id,
|
|
change_type,
|
|
count() AS cnt
|
|
FROM change_events
|
|
WHERE {where_clause}
|
|
GROUP BY run_id, change_type
|
|
"""
|
|
type_rows = client.execute(type_query)
|
|
|
|
# Build run_id -> {type: count} mapping
|
|
type_map: Dict[int, Dict[str, int]] = {}
|
|
for tr in type_rows:
|
|
rid = tr[0]
|
|
if rid not in type_map:
|
|
type_map[rid] = {}
|
|
type_map[rid][tr[1]] = tr[2]
|
|
|
|
return {
|
|
"analysis_id": analysis_id,
|
|
"source": "clickhouse",
|
|
"stats": [
|
|
{
|
|
"run_id": r[0],
|
|
"run_number": r[1],
|
|
"total_changes": r[2],
|
|
"by_risk": {
|
|
"critical": r[3],
|
|
"high": r[4],
|
|
"medium": r[5],
|
|
"low": r[6]
|
|
},
|
|
"by_type": type_map.get(r[0], {}),
|
|
"first_change_at": r[7].isoformat() if r[7] else None,
|
|
"last_change_at": r[8].isoformat() if r[8] else None
|
|
}
|
|
for r in rows
|
|
]
|
|
}
|
|
except Exception as e:
|
|
logger.error("Failed to fetch run stats from ClickHouse", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
class RunComparisonResult(BaseModel):
|
|
"""Result of comparing two runs"""
|
|
analysis_id: int
|
|
run_a: Dict[str, Any]
|
|
run_b: Dict[str, Any]
|
|
comparison: Dict[str, Any]
|
|
changes_only_in_a: List[Dict[str, Any]]
|
|
changes_only_in_b: List[Dict[str, Any]]
|
|
common_changes: List[Dict[str, Any]]
|
|
|
|
|
|
@router.get("/changes/runs/{analysis_id}/compare", response_model=RunComparisonResult)
|
|
async def compare_runs(
|
|
analysis_id: int,
|
|
run_a: int = Query(..., description="First run number to compare"),
|
|
run_b: int = Query(..., description="Second run number to compare"),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Compare changes between two runs of the same analysis.
|
|
|
|
Returns:
|
|
- run_a, run_b: Metadata about each run
|
|
- comparison: Summary statistics (added, removed, common)
|
|
- changes_only_in_a: Changes that only occurred in run A
|
|
- changes_only_in_b: Changes that only occurred in run B (new in this run)
|
|
- common_changes: Changes that occurred in both runs
|
|
|
|
Use case: Compare what changed between deployments, config changes, etc.
|
|
"""
|
|
# Validate run numbers are different
|
|
if run_a == run_b:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="run_a and run_b must be different run numbers"
|
|
)
|
|
|
|
# Validate run numbers are positive
|
|
if run_a < 1 or run_b < 1:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Run numbers must be positive integers"
|
|
)
|
|
|
|
try:
|
|
from database.clickhouse import get_clickhouse_client
|
|
from database.postgresql import database
|
|
|
|
client = get_clickhouse_client()
|
|
|
|
# Get run metadata from PostgreSQL
|
|
# Using explicit OR instead of IN for better compatibility with parameter binding
|
|
run_query = """
|
|
SELECT id, run_number, status, start_time, end_time,
|
|
events_collected, communications_discovered
|
|
FROM analysis_runs
|
|
WHERE analysis_id = :analysis_id
|
|
AND (run_number = :run_a OR run_number = :run_b)
|
|
ORDER BY run_number
|
|
"""
|
|
runs = await database.fetch_all(run_query, {
|
|
"analysis_id": analysis_id,
|
|
"run_a": run_a,
|
|
"run_b": run_b
|
|
})
|
|
|
|
run_a_meta = None
|
|
run_b_meta = None
|
|
for r in runs:
|
|
if r["run_number"] == run_a:
|
|
run_a_meta = dict(r)
|
|
elif r["run_number"] == run_b:
|
|
run_b_meta = dict(r)
|
|
|
|
if not run_a_meta or not run_b_meta:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"One or both runs not found. Run A: {run_a}, Run B: {run_b}"
|
|
)
|
|
|
|
ch_aid = await _get_ch_analysis_id(analysis_id)
|
|
|
|
# Get changes from run A
|
|
changes_a_query = f"""
|
|
SELECT
|
|
change_type, target_name, target_namespace, risk_level,
|
|
details, before_state, after_state, detected_at
|
|
FROM change_events
|
|
WHERE analysis_id = '{ch_aid}' AND run_number = {run_a}
|
|
ORDER BY detected_at
|
|
"""
|
|
changes_a_raw = client.execute(changes_a_query)
|
|
|
|
# Get changes from run B
|
|
changes_b_query = f"""
|
|
SELECT
|
|
change_type, target_name, target_namespace, risk_level,
|
|
details, before_state, after_state, detected_at
|
|
FROM change_events
|
|
WHERE analysis_id = '{ch_aid}' AND run_number = {run_b}
|
|
ORDER BY detected_at
|
|
"""
|
|
changes_b_raw = client.execute(changes_b_query)
|
|
|
|
# Convert to dicts and create fingerprints for comparison
|
|
def parse_json_field(value):
|
|
"""Parse JSON string to dict, return empty dict on failure"""
|
|
if not value:
|
|
return {}
|
|
if isinstance(value, dict):
|
|
return value
|
|
try:
|
|
return json.loads(value)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return {}
|
|
|
|
def change_to_dict(row):
|
|
return {
|
|
"change_type": row[0] or 'unknown',
|
|
"target_name": row[1] or '',
|
|
"target_namespace": row[2] or '',
|
|
"risk_level": row[3] or 'medium',
|
|
"details": row[4] or '',
|
|
"before_state": parse_json_field(row[5]),
|
|
"after_state": parse_json_field(row[6]),
|
|
"detected_at": row[7].isoformat() if row[7] else None
|
|
}
|
|
|
|
def fingerprint(change):
|
|
"""Create a fingerprint for comparing changes (type + target)"""
|
|
# Defensive: ensure no None values in fingerprint
|
|
ct = change.get('change_type') or 'unknown'
|
|
ns = change.get('target_namespace') or ''
|
|
name = change.get('target_name') or ''
|
|
return f"{ct}:{ns}/{name}"
|
|
|
|
changes_a = [change_to_dict(r) for r in changes_a_raw]
|
|
changes_b = [change_to_dict(r) for r in changes_b_raw]
|
|
|
|
# Create fingerprint sets
|
|
fps_a = {fingerprint(c): c for c in changes_a}
|
|
fps_b = {fingerprint(c): c for c in changes_b}
|
|
|
|
fps_a_set = set(fps_a.keys())
|
|
fps_b_set = set(fps_b.keys())
|
|
|
|
# Calculate differences
|
|
only_in_a = fps_a_set - fps_b_set
|
|
only_in_b = fps_b_set - fps_a_set
|
|
in_both = fps_a_set & fps_b_set
|
|
|
|
changes_only_in_a = [fps_a[fp] for fp in only_in_a]
|
|
changes_only_in_b = [fps_b[fp] for fp in only_in_b]
|
|
common_changes = [fps_b[fp] for fp in in_both] # Use run B version
|
|
|
|
# Calculate stats by type
|
|
def count_by_type(changes):
|
|
counts = {}
|
|
for c in changes:
|
|
t = c["change_type"]
|
|
counts[t] = counts.get(t, 0) + 1
|
|
return counts
|
|
|
|
def count_by_risk(changes):
|
|
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
|
for c in changes:
|
|
r = c["risk_level"]
|
|
if r in counts:
|
|
counts[r] += 1
|
|
return counts
|
|
|
|
# Format run metadata for response
|
|
def format_run_meta(meta):
|
|
# Use raw change count for consistency with comparison.total_in_run_X
|
|
total = len(changes_a) if meta["run_number"] == run_a else len(changes_b)
|
|
return {
|
|
"run_id": meta["id"],
|
|
"run_number": meta["run_number"],
|
|
"status": meta["status"],
|
|
"start_time": meta["start_time"].isoformat() if meta["start_time"] else None,
|
|
"end_time": meta["end_time"].isoformat() if meta["end_time"] else None,
|
|
"total_changes": total,
|
|
"events_collected": meta["events_collected"],
|
|
"communications_discovered": meta["communications_discovered"]
|
|
}
|
|
|
|
return RunComparisonResult(
|
|
analysis_id=analysis_id,
|
|
run_a=format_run_meta(run_a_meta),
|
|
run_b=format_run_meta(run_b_meta),
|
|
comparison={
|
|
"total_in_run_a": len(changes_a),
|
|
"total_in_run_b": len(changes_b),
|
|
"only_in_run_a": len(changes_only_in_a),
|
|
"only_in_run_b": len(changes_only_in_b),
|
|
"common": len(common_changes),
|
|
"by_type": {
|
|
"only_in_a": count_by_type(changes_only_in_a),
|
|
"only_in_b": count_by_type(changes_only_in_b),
|
|
"common": count_by_type(common_changes)
|
|
},
|
|
"by_risk": {
|
|
"only_in_a": count_by_risk(changes_only_in_a),
|
|
"only_in_b": count_by_risk(changes_only_in_b),
|
|
"common": count_by_risk(common_changes)
|
|
},
|
|
"summary": {
|
|
"new_in_b": f"{len(changes_only_in_b)} new changes in Run {run_b}",
|
|
"removed_from_a": f"{len(changes_only_in_a)} changes from Run {run_a} not in Run {run_b}",
|
|
"persistent": f"{len(common_changes)} changes in both runs"
|
|
}
|
|
},
|
|
changes_only_in_a=changes_only_in_a,
|
|
changes_only_in_b=changes_only_in_b,
|
|
common_changes=common_changes
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to compare runs", analysis_id=analysis_id, error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/changes/config")
|
|
async def get_change_detection_config(
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get the current change detection configuration.
|
|
|
|
Returns:
|
|
- storage: ClickHouse (PostgreSQL change_events table removed)
|
|
- run_based_filtering: Whether run-based filtering is enabled
|
|
- worker_enabled: Whether the background worker is enabled
|
|
- detection_interval: How often detection runs
|
|
- lookback_minutes: How far back detection looks
|
|
"""
|
|
return {
|
|
"storage": "clickhouse",
|
|
"run_based_filtering": RUN_BASED_FILTERING,
|
|
"architecture": "ClickHouse-only (PostgreSQL change_events removed)",
|
|
"feature_flags": {
|
|
"RUN_BASED_FILTERING_ENABLED": RUN_BASED_FILTERING
|
|
},
|
|
"worker_enabled_env": "CHANGE_DETECTION_ENABLED",
|
|
"detection_interval_env": "CHANGE_DETECTION_INTERVAL",
|
|
"lookback_minutes_env": "CHANGE_DETECTION_LOOKBACK_MINUTES",
|
|
"documentation": {
|
|
"storage_note": "Change events stored ONLY in ClickHouse. PostgreSQL table removed.",
|
|
"enable_run_filtering": "Set RUN_BASED_FILTERING_ENABLED=true to enable run-based filtering UI",
|
|
"enable_worker": "Set CHANGE_DETECTION_ENABLED=true to start the background detection worker",
|
|
"adjust_interval": "Set CHANGE_DETECTION_INTERVAL=60 (seconds) to control detection frequency",
|
|
"adjust_lookback": "Set CHANGE_DETECTION_LOOKBACK_MINUTES=5 to control detection window"
|
|
}
|
|
}
|
|
|
|
|
|
# ============ Sprint 5: Impact Analysis Endpoints ============
|
|
|
|
@router.get("/changes/impact/analyze")
|
|
async def analyze_change_impact(
|
|
cluster_id: int = Query(..., description="Cluster ID"),
|
|
change_id: Optional[str] = Query(None, description="Change event ID (UUID from ClickHouse)"),
|
|
workload: Optional[str] = Query(None, description="Target workload name"),
|
|
namespace: Optional[str] = Query(None, description="Target namespace"),
|
|
change_type: Optional[str] = Query(None, description="Type of change"),
|
|
current_user: dict = Depends(get_current_user),
|
|
change_service: ChangeDetectionService = Depends(get_change_detection_service)
|
|
):
|
|
"""
|
|
Analyze the impact of a change on the infrastructure.
|
|
|
|
This endpoint provides:
|
|
- Blast radius calculation (direct, indirect, cascade)
|
|
- Affected services list with impact categorization
|
|
- Graph structure for visualization
|
|
- Risk score and recommendations
|
|
|
|
You can either:
|
|
- Provide a change_id to analyze an existing change
|
|
- Provide workload + namespace to analyze a hypothetical change
|
|
"""
|
|
logger.info(
|
|
"Impact analysis requested",
|
|
cluster_id=cluster_id,
|
|
change_id=change_id,
|
|
workload=workload,
|
|
namespace=namespace
|
|
)
|
|
|
|
try:
|
|
result = await change_service.analyze_change_impact(
|
|
cluster_id=cluster_id,
|
|
change_id=change_id,
|
|
workload_name=workload,
|
|
namespace=namespace,
|
|
change_type=change_type
|
|
)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error("Impact analysis failed", error=str(e))
|
|
return {
|
|
"error": str(e),
|
|
"blast_radius": {"total": 0, "direct": 0, "indirect": 0, "cascade": 0},
|
|
"affected_services": [],
|
|
"impact_graph": {"nodes": [], "edges": []},
|
|
"risk_score": 0
|
|
}
|
|
|
|
|
|
@router.get("/changes/{change_id}/impact")
|
|
async def get_change_impact(
|
|
change_id: str,
|
|
current_user: dict = Depends(get_current_user),
|
|
change_service: ChangeDetectionService = Depends(get_change_detection_service)
|
|
):
|
|
"""
|
|
Get impact analysis for a specific change event from ClickHouse.
|
|
|
|
Shorthand endpoint that automatically fetches the change details
|
|
and runs impact analysis.
|
|
"""
|
|
_validate_change_id(change_id)
|
|
try:
|
|
from database.clickhouse import get_clickhouse_client
|
|
|
|
client = get_clickhouse_client()
|
|
|
|
query = """
|
|
SELECT
|
|
event_id,
|
|
cluster_id,
|
|
change_type,
|
|
target_name,
|
|
target_namespace,
|
|
risk_level,
|
|
affected_services
|
|
FROM change_events
|
|
WHERE event_id = %(change_id)s
|
|
LIMIT 1
|
|
"""
|
|
|
|
result = client.execute(query, {"change_id": change_id})
|
|
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Change not found")
|
|
|
|
row = result[0]
|
|
analysis_result = await change_service.analyze_change_impact(
|
|
cluster_id=row[1],
|
|
change_id=change_id,
|
|
workload_name=row[3],
|
|
namespace=row[4] or "unknown",
|
|
change_type=row[2]
|
|
)
|
|
|
|
return analysis_result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Impact analysis failed", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/changes/{change_id}/correlated")
|
|
async def get_correlated_changes(
|
|
change_id: str,
|
|
cluster_id: int = Query(..., description="Cluster ID"),
|
|
time_window: int = Query(30, ge=5, le=120, description="Time window in minutes"),
|
|
current_user: dict = Depends(get_current_user),
|
|
change_service: ChangeDetectionService = Depends(get_change_detection_service)
|
|
):
|
|
"""
|
|
Find changes that are correlated with a given change from ClickHouse.
|
|
|
|
Correlation is based on:
|
|
- Time proximity (within the specified window)
|
|
- Same source/actor (changed_by)
|
|
- Same namespace
|
|
- Related workload names
|
|
|
|
This helps identify:
|
|
- Deployment-related changes (multiple services deployed together)
|
|
- Cascade effects (one change triggering others)
|
|
- Configuration drift patterns
|
|
"""
|
|
_validate_change_id(change_id)
|
|
try:
|
|
correlated = await change_service.get_correlated_changes(
|
|
cluster_id=cluster_id,
|
|
change_id=change_id,
|
|
time_window_minutes=time_window
|
|
)
|
|
|
|
return {
|
|
"reference_change_id": change_id,
|
|
"time_window_minutes": time_window,
|
|
"correlated_changes": correlated,
|
|
"total_correlated": len(correlated),
|
|
"correlation_types": {
|
|
"same_source": sum(1 for c in correlated if c.get("correlation_type") == "same_source"),
|
|
"same_namespace": sum(1 for c in correlated if c.get("correlation_type") == "same_namespace"),
|
|
"time_proximity": sum(1 for c in correlated if c.get("correlation_type") == "time_proximity")
|
|
}
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Correlation analysis failed", error=str(e))
|
|
return {
|
|
"reference_change_id": change_id,
|
|
"correlated_changes": [],
|
|
"total_correlated": 0,
|
|
"error": str(e)
|
|
}
|
|
|
|
|
|
# ============ Sprint 6: Enterprise Features ============
|
|
|
|
# ------------ 6.1: Baseline & Drift Detection ------------
|
|
|
|
@router.post("/changes/baseline/{analysis_id}")
|
|
async def mark_as_baseline(
|
|
analysis_id: int,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Mark an analysis as the baseline for drift detection.
|
|
|
|
Only one analysis per cluster can be the baseline at a time.
|
|
Marking a new baseline will unmark the previous one.
|
|
"""
|
|
from database.postgresql import database
|
|
|
|
try:
|
|
# Get the analysis to find cluster_id
|
|
analysis_query = "SELECT cluster_id FROM analyses WHERE id = :analysis_id"
|
|
analysis = await database.fetch_one(analysis_query, {"analysis_id": analysis_id})
|
|
|
|
if not analysis:
|
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
|
|
|
cluster_id = analysis["cluster_id"]
|
|
username = current_user.get("username", "unknown")
|
|
|
|
# Unmark any existing baseline for this cluster
|
|
unmark_query = """
|
|
UPDATE analyses
|
|
SET is_baseline = false, baseline_marked_at = NULL, baseline_marked_by = NULL
|
|
WHERE cluster_id = :cluster_id AND is_baseline = true
|
|
"""
|
|
await database.execute(unmark_query, {"cluster_id": cluster_id})
|
|
|
|
# Mark the new baseline
|
|
mark_query = """
|
|
UPDATE analyses
|
|
SET is_baseline = true, baseline_marked_at = NOW(), baseline_marked_by = :marked_by
|
|
WHERE id = :analysis_id
|
|
"""
|
|
await database.execute(mark_query, {
|
|
"analysis_id": analysis_id,
|
|
"marked_by": username
|
|
})
|
|
|
|
logger.info("Analysis marked as baseline", analysis_id=analysis_id, by=username)
|
|
|
|
return {
|
|
"success": True,
|
|
"analysis_id": analysis_id,
|
|
"cluster_id": cluster_id,
|
|
"marked_by": username,
|
|
"message": f"Analysis #{analysis_id} is now the baseline for cluster #{cluster_id}"
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to mark baseline", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.delete("/changes/baseline/{cluster_id}")
|
|
async def unmark_baseline(
|
|
cluster_id: int,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Remove the baseline marking for a cluster.
|
|
"""
|
|
from database.postgresql import database
|
|
|
|
try:
|
|
query = """
|
|
UPDATE analyses
|
|
SET is_baseline = false, baseline_marked_at = NULL, baseline_marked_by = NULL
|
|
WHERE cluster_id = :cluster_id AND is_baseline = true
|
|
RETURNING id
|
|
"""
|
|
result = await database.fetch_one(query, {"cluster_id": cluster_id})
|
|
|
|
if not result:
|
|
return {"success": True, "message": "No baseline was set for this cluster"}
|
|
|
|
return {
|
|
"success": True,
|
|
"previous_baseline_id": result["id"],
|
|
"message": f"Baseline removed from cluster #{cluster_id}"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to unmark baseline", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/changes/baseline/{cluster_id}")
|
|
async def get_baseline(
|
|
cluster_id: int,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get the current baseline analysis for a cluster.
|
|
"""
|
|
from database.postgresql import database
|
|
|
|
try:
|
|
query = """
|
|
SELECT id, name, status, baseline_marked_at, baseline_marked_by,
|
|
(SELECT COUNT(*) FROM workloads WHERE cluster_id = :cluster_id AND is_active = true) as workload_count,
|
|
(SELECT COUNT(*) FROM communications WHERE cluster_id = :cluster_id AND is_active = true) as connection_count
|
|
FROM analyses
|
|
WHERE cluster_id = :cluster_id AND is_baseline = true
|
|
"""
|
|
result = await database.fetch_one(query, {"cluster_id": cluster_id})
|
|
|
|
if not result:
|
|
return {
|
|
"has_baseline": False,
|
|
"cluster_id": cluster_id,
|
|
"message": "No baseline set for this cluster"
|
|
}
|
|
|
|
return {
|
|
"has_baseline": True,
|
|
"cluster_id": cluster_id,
|
|
"baseline": {
|
|
"analysis_id": result["id"],
|
|
"name": result["name"],
|
|
"status": result["status"],
|
|
"marked_at": result["baseline_marked_at"].isoformat() if result["baseline_marked_at"] else None,
|
|
"marked_by": result["baseline_marked_by"],
|
|
"workload_count": result["workload_count"],
|
|
"connection_count": result["connection_count"]
|
|
}
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to get baseline", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/changes/drift/{cluster_id}")
|
|
async def detect_drift(
|
|
cluster_id: int,
|
|
analysis_id: Optional[int] = Query(None, description="Analysis to compare (defaults to latest)"),
|
|
current_user: dict = Depends(get_current_user),
|
|
change_service: ChangeDetectionService = Depends(get_change_detection_service)
|
|
):
|
|
"""
|
|
Detect drift between current state and baseline.
|
|
|
|
Returns:
|
|
- has_drift: Whether drift was detected
|
|
- drift_summary: Summary of what changed
|
|
- workloads_added/removed: Lists of workload changes
|
|
- connections_added/removed: Lists of connection changes
|
|
- drift_severity: low, medium, high based on change count
|
|
"""
|
|
from database.postgresql import database
|
|
|
|
try:
|
|
# Get baseline
|
|
baseline_query = """
|
|
SELECT id, name, baseline_marked_at
|
|
FROM analyses
|
|
WHERE cluster_id = :cluster_id AND is_baseline = true
|
|
"""
|
|
baseline = await database.fetch_one(baseline_query, {"cluster_id": cluster_id})
|
|
|
|
if not baseline:
|
|
return {
|
|
"has_baseline": False,
|
|
"error": "No baseline set for this cluster. Mark an analysis as baseline first."
|
|
}
|
|
|
|
# Get current analysis (latest or specified)
|
|
if analysis_id:
|
|
current_query = "SELECT id, name FROM analyses WHERE id = :analysis_id"
|
|
current = await database.fetch_one(current_query, {"analysis_id": analysis_id})
|
|
else:
|
|
current_query = """
|
|
SELECT id, name FROM analyses
|
|
WHERE cluster_id = :cluster_id AND status IN ('running', 'completed', 'stopped')
|
|
ORDER BY created_at DESC LIMIT 1
|
|
"""
|
|
current = await database.fetch_one(current_query, {"cluster_id": cluster_id})
|
|
|
|
if not current:
|
|
return {
|
|
"has_baseline": True,
|
|
"error": "No current analysis found for comparison"
|
|
}
|
|
|
|
# If comparing baseline to itself
|
|
if baseline["id"] == current["id"]:
|
|
return {
|
|
"has_baseline": True,
|
|
"has_drift": False,
|
|
"baseline_analysis_id": baseline["id"],
|
|
"current_analysis_id": current["id"],
|
|
"message": "Current analysis is the baseline - no drift possible"
|
|
}
|
|
|
|
# Get workloads for baseline
|
|
workloads_query = """
|
|
SELECT DISTINCT w.name, n.name as namespace
|
|
FROM workloads w
|
|
JOIN namespaces n ON w.namespace_id = n.id
|
|
WHERE w.cluster_id = :cluster_id AND w.is_active = true
|
|
"""
|
|
|
|
# For drift detection, we compare current active workloads
|
|
# In a more sophisticated implementation, we'd snapshot at baseline time
|
|
baseline_workloads = await database.fetch_all(workloads_query, {"cluster_id": cluster_id})
|
|
current_workloads = await database.fetch_all(workloads_query, {"cluster_id": cluster_id})
|
|
|
|
baseline_names = set(f"{w['namespace']}/{w['name']}" for w in baseline_workloads)
|
|
current_names = set(f"{w['namespace']}/{w['name']}" for w in current_workloads)
|
|
|
|
workloads_added = list(current_names - baseline_names)
|
|
workloads_removed = list(baseline_names - current_names)
|
|
|
|
# Get connections
|
|
connections_query = """
|
|
SELECT COUNT(*) as count FROM communications
|
|
WHERE cluster_id = :cluster_id AND is_active = true
|
|
"""
|
|
baseline_connections = await database.fetch_one(connections_query, {"cluster_id": cluster_id})
|
|
current_connections = await database.fetch_one(connections_query, {"cluster_id": cluster_id})
|
|
|
|
connection_delta = (current_connections["count"] if current_connections else 0) - \
|
|
(baseline_connections["count"] if baseline_connections else 0)
|
|
|
|
# Calculate drift severity
|
|
total_changes = len(workloads_added) + len(workloads_removed) + abs(connection_delta)
|
|
if total_changes == 0:
|
|
drift_severity = "none"
|
|
elif total_changes <= 5:
|
|
drift_severity = "low"
|
|
elif total_changes <= 15:
|
|
drift_severity = "medium"
|
|
else:
|
|
drift_severity = "high"
|
|
|
|
has_drift = total_changes > 0
|
|
|
|
return {
|
|
"has_baseline": True,
|
|
"has_drift": has_drift,
|
|
"baseline_analysis_id": baseline["id"],
|
|
"baseline_name": baseline["name"],
|
|
"baseline_marked_at": baseline["baseline_marked_at"].isoformat() if baseline["baseline_marked_at"] else None,
|
|
"current_analysis_id": current["id"],
|
|
"current_name": current["name"],
|
|
"drift_summary": {
|
|
"total_changes": total_changes,
|
|
"workloads_added": len(workloads_added),
|
|
"workloads_removed": len(workloads_removed),
|
|
"connection_delta": connection_delta
|
|
},
|
|
"drift_severity": drift_severity,
|
|
"drift_details": {
|
|
"workloads_added": workloads_added[:20], # Limit to 20
|
|
"workloads_removed": workloads_removed[:20],
|
|
}
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Drift detection failed", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# ------------ 6.2: Change Review Workflow ------------
|
|
|
|
@router.post("/changes/{change_id}/acknowledge")
|
|
async def acknowledge_change(
|
|
change_id: int,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Acknowledge a change (quick action to mark as seen).
|
|
"""
|
|
from database.postgresql import database
|
|
|
|
try:
|
|
username = current_user.get("username", "unknown")
|
|
|
|
query = """
|
|
UPDATE change_events
|
|
SET status = 'acknowledged', acknowledged_at = NOW(), acknowledged_by = :username
|
|
WHERE id = :change_id AND status = 'new'
|
|
RETURNING id
|
|
"""
|
|
result = await database.fetch_one(query, {
|
|
"change_id": change_id,
|
|
"username": username
|
|
})
|
|
|
|
if not result:
|
|
# Check if already acknowledged
|
|
check_query = "SELECT status FROM change_events WHERE id = :change_id"
|
|
existing = await database.fetch_one(check_query, {"change_id": change_id})
|
|
if existing:
|
|
return {"success": True, "message": f"Change already in status: {existing['status']}"}
|
|
raise HTTPException(status_code=404, detail="Change not found")
|
|
|
|
return {
|
|
"success": True,
|
|
"change_id": change_id,
|
|
"status": "acknowledged",
|
|
"acknowledged_by": username
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to acknowledge change", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/changes/{change_id}/review")
|
|
async def review_change(
|
|
change_id: int,
|
|
action: str = Query(..., description="Review action: approve, reject, resolve"),
|
|
comment: Optional[str] = Query(None, description="Review comment"),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Review a change (approve, reject, or resolve).
|
|
"""
|
|
from database.postgresql import database
|
|
|
|
if action not in ['approve', 'reject', 'resolve', 'reviewing']:
|
|
raise HTTPException(status_code=400, detail="Invalid action. Use: approve, reject, resolve, reviewing")
|
|
|
|
try:
|
|
username = current_user.get("username", "unknown")
|
|
|
|
# Map action to status
|
|
status_map = {
|
|
'approve': 'approved',
|
|
'reject': 'rejected',
|
|
'resolve': 'resolved',
|
|
'reviewing': 'reviewing'
|
|
}
|
|
new_status = status_map[action]
|
|
|
|
query = """
|
|
UPDATE change_events
|
|
SET status = :status,
|
|
reviewed_at = NOW(),
|
|
reviewed_by = :username,
|
|
review_comment = COALESCE(:comment, review_comment)
|
|
WHERE id = :change_id
|
|
RETURNING id, status
|
|
"""
|
|
result = await database.fetch_one(query, {
|
|
"change_id": change_id,
|
|
"status": new_status,
|
|
"username": username,
|
|
"comment": comment
|
|
})
|
|
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Change not found")
|
|
|
|
logger.info("Change reviewed", change_id=change_id, action=action, by=username)
|
|
|
|
return {
|
|
"success": True,
|
|
"change_id": change_id,
|
|
"status": new_status,
|
|
"reviewed_by": username,
|
|
"comment": comment
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to review change", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/changes/workflow/pending")
|
|
async def get_pending_reviews(
|
|
cluster_id: Optional[int] = Query(None, description="Filter by cluster"),
|
|
status: Optional[str] = Query(None, description="Filter by status"),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get changes pending review.
|
|
"""
|
|
from database.postgresql import database
|
|
|
|
try:
|
|
conditions = ["1=1"]
|
|
params = {"limit": limit}
|
|
|
|
if cluster_id:
|
|
conditions.append("ce.cluster_id = :cluster_id")
|
|
params["cluster_id"] = cluster_id
|
|
|
|
if status:
|
|
conditions.append("ce.status = :status")
|
|
params["status"] = status
|
|
else:
|
|
conditions.append("ce.status IN ('new', 'acknowledged', 'reviewing')")
|
|
|
|
where_clause = " AND ".join(conditions)
|
|
|
|
query = f"""
|
|
SELECT
|
|
ce.id,
|
|
ce.detected_at as timestamp,
|
|
ce.change_type,
|
|
ce.target,
|
|
n.name as namespace,
|
|
ce.risk_level as risk,
|
|
ce.affected_services,
|
|
ce.status,
|
|
ce.acknowledged_at,
|
|
ce.acknowledged_by,
|
|
ce.reviewed_at,
|
|
ce.reviewed_by,
|
|
ce.review_comment
|
|
FROM change_events ce
|
|
LEFT JOIN namespaces n ON ce.namespace_id = n.id
|
|
WHERE {where_clause}
|
|
ORDER BY
|
|
CASE ce.risk_level
|
|
WHEN 'critical' THEN 1
|
|
WHEN 'high' THEN 2
|
|
WHEN 'medium' THEN 3
|
|
ELSE 4
|
|
END,
|
|
ce.detected_at DESC
|
|
LIMIT :limit
|
|
"""
|
|
|
|
results = await database.fetch_all(query, params)
|
|
|
|
return {
|
|
"changes": [dict(r) for r in results],
|
|
"total": len(results),
|
|
"filters": {
|
|
"cluster_id": cluster_id,
|
|
"status": status
|
|
}
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to get pending reviews", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# ------------ 6.3: Notification Hooks ------------
|
|
|
|
@router.get("/changes/hooks/{cluster_id}")
|
|
async def get_notification_hooks(
|
|
cluster_id: int,
|
|
enabled_only: bool = Query(True, description="Only return enabled hooks"),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get notification hooks configured for a cluster.
|
|
"""
|
|
from services.notification_service import get_notification_service
|
|
|
|
try:
|
|
service = get_notification_service()
|
|
hooks = await service.get_hooks_for_cluster(cluster_id, enabled_only)
|
|
|
|
# Don't expose sensitive config details
|
|
safe_hooks = []
|
|
for hook in hooks:
|
|
safe_hook = {
|
|
"id": hook.get("id"),
|
|
"name": hook.get("name"),
|
|
"hook_type": hook.get("hook_type"),
|
|
"is_enabled": hook.get("is_enabled"),
|
|
"trigger_on_critical": hook.get("trigger_on_critical"),
|
|
"trigger_on_high": hook.get("trigger_on_high"),
|
|
"trigger_on_medium": hook.get("trigger_on_medium"),
|
|
"trigger_on_low": hook.get("trigger_on_low"),
|
|
"trigger_change_types": hook.get("trigger_change_types"),
|
|
"rate_limit_per_hour": hook.get("rate_limit_per_hour"),
|
|
"last_triggered_at": hook.get("last_triggered_at"),
|
|
"created_at": hook.get("created_at"),
|
|
"created_by": hook.get("created_by"),
|
|
}
|
|
safe_hooks.append(safe_hook)
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"hooks": safe_hooks,
|
|
"total": len(safe_hooks)
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to get notification hooks", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/changes/hooks/{cluster_id}")
|
|
async def create_notification_hook(
|
|
cluster_id: int,
|
|
name: str = Query(..., description="Hook name"),
|
|
hook_type: str = Query(..., description="Hook type: slack, teams, email, webhook"),
|
|
config: str = Query(..., description="JSON configuration for the hook"),
|
|
trigger_on_critical: bool = Query(True),
|
|
trigger_on_high: bool = Query(True),
|
|
trigger_on_medium: bool = Query(False),
|
|
trigger_on_low: bool = Query(False),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Create a new notification hook.
|
|
|
|
Config examples:
|
|
- Slack: {"webhook_url": "https://hooks.slack.com/services/..."}
|
|
- Teams: {"webhook_url": "https://outlook.office.com/webhook/..."}
|
|
- Webhook: {"url": "https://...", "method": "POST", "headers": {}}
|
|
- Email: {"recipients": ["email@example.com"]}
|
|
"""
|
|
import json as json_lib
|
|
from services.notification_service import get_notification_service, HookType
|
|
|
|
# Validate hook type
|
|
valid_types = [e.value for e in HookType]
|
|
if hook_type not in valid_types:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid hook type. Valid types: {valid_types}"
|
|
)
|
|
|
|
# Parse config
|
|
try:
|
|
config_dict = json_lib.loads(config)
|
|
except json_lib.JSONDecodeError:
|
|
raise HTTPException(status_code=400, detail="Invalid JSON config")
|
|
|
|
try:
|
|
service = get_notification_service()
|
|
result = await service.create_hook(
|
|
cluster_id=cluster_id,
|
|
name=name,
|
|
hook_type=hook_type,
|
|
config=config_dict,
|
|
trigger_on_critical=trigger_on_critical,
|
|
trigger_on_high=trigger_on_high,
|
|
trigger_on_medium=trigger_on_medium,
|
|
trigger_on_low=trigger_on_low,
|
|
created_by=current_user.get("username", "unknown")
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"hook": result
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to create notification hook", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.put("/changes/hooks/{hook_id}")
|
|
async def update_notification_hook(
|
|
hook_id: int,
|
|
name: Optional[str] = Query(None),
|
|
is_enabled: Optional[bool] = Query(None),
|
|
trigger_on_critical: Optional[bool] = Query(None),
|
|
trigger_on_high: Optional[bool] = Query(None),
|
|
trigger_on_medium: Optional[bool] = Query(None),
|
|
trigger_on_low: Optional[bool] = Query(None),
|
|
rate_limit_per_hour: Optional[int] = Query(None),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Update a notification hook.
|
|
"""
|
|
from services.notification_service import get_notification_service
|
|
|
|
updates = {}
|
|
if name is not None:
|
|
updates["name"] = name
|
|
if is_enabled is not None:
|
|
updates["is_enabled"] = is_enabled
|
|
if trigger_on_critical is not None:
|
|
updates["trigger_on_critical"] = trigger_on_critical
|
|
if trigger_on_high is not None:
|
|
updates["trigger_on_high"] = trigger_on_high
|
|
if trigger_on_medium is not None:
|
|
updates["trigger_on_medium"] = trigger_on_medium
|
|
if trigger_on_low is not None:
|
|
updates["trigger_on_low"] = trigger_on_low
|
|
if rate_limit_per_hour is not None:
|
|
updates["rate_limit_per_hour"] = rate_limit_per_hour
|
|
|
|
if not updates:
|
|
raise HTTPException(status_code=400, detail="No updates provided")
|
|
|
|
try:
|
|
service = get_notification_service()
|
|
success = await service.update_hook(hook_id, updates)
|
|
|
|
return {
|
|
"success": success,
|
|
"hook_id": hook_id,
|
|
"updates": updates
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to update notification hook", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.delete("/changes/hooks/{hook_id}")
|
|
async def delete_notification_hook(
|
|
hook_id: int,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Delete a notification hook.
|
|
"""
|
|
from services.notification_service import get_notification_service
|
|
|
|
try:
|
|
service = get_notification_service()
|
|
success = await service.delete_hook(hook_id)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Hook not found")
|
|
|
|
return {
|
|
"success": True,
|
|
"hook_id": hook_id,
|
|
"message": "Hook deleted"
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to delete notification hook", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/changes/hooks/{hook_id}/test")
|
|
async def test_notification_hook(
|
|
hook_id: int,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Test a notification hook with a sample change.
|
|
"""
|
|
from services.notification_service import get_notification_service
|
|
from database.postgresql import database
|
|
|
|
try:
|
|
# Get the hook
|
|
query = "SELECT * FROM notification_hooks WHERE id = :hook_id"
|
|
hook = await database.fetch_one(query, {"hook_id": hook_id})
|
|
|
|
if not hook:
|
|
raise HTTPException(status_code=404, detail="Hook not found")
|
|
|
|
# Create test change
|
|
test_change = {
|
|
"id": 0,
|
|
"change_type": "test_notification",
|
|
"target": "test-workload",
|
|
"namespace": "test-namespace",
|
|
"risk_level": "high",
|
|
"affected_services": 3,
|
|
"details": "This is a test notification from Flowfish Change Detection.",
|
|
"changed_by": current_user.get("username", "test-user")
|
|
}
|
|
|
|
service = get_notification_service()
|
|
success = await service.send_notification(dict(hook), test_change)
|
|
|
|
return {
|
|
"success": success,
|
|
"hook_id": hook_id,
|
|
"hook_type": hook["hook_type"],
|
|
"message": "Test notification sent" if success else "Failed to send test notification"
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to test notification hook", error=str(e))
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# ============================================
|
|
# Error Anomaly Summary API
|
|
# ============================================
|
|
|
|
class ErrorAnomalySummaryResponse(BaseModel):
|
|
"""Error anomaly summary for Network Explorer and Dashboard"""
|
|
total_anomalies: int = 0
|
|
by_error_type: dict = {}
|
|
affected_connections: List[dict] = []
|
|
trends: dict = {}
|
|
cluster_id: Optional[int] = None
|
|
analysis_id: Optional[int] = None
|
|
|
|
|
|
@router.get("/changes/errors/summary", response_model=ErrorAnomalySummaryResponse)
|
|
async def get_error_anomaly_summary(
|
|
cluster_id: int = Query(..., description="Cluster ID"),
|
|
analysis_id: Optional[int] = Query(None, description="Analysis ID"),
|
|
time_range: str = Query("24h", description="Time range: 1h, 6h, 24h, 7d"),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get error anomaly summary from change_events table.
|
|
|
|
This endpoint returns aggregated error anomaly data for display
|
|
in Network Explorer, Dashboard, and Map pages.
|
|
|
|
Time ranges:
|
|
- 1h: Last hour
|
|
- 6h: Last 6 hours
|
|
- 24h: Last 24 hours (default)
|
|
- 7d: Last 7 days
|
|
"""
|
|
import asyncio
|
|
|
|
# Resolve formatted analysis_id in async context before entering sync function
|
|
_resolved_ch_aid = await _get_ch_analysis_id(analysis_id, cluster_id) if analysis_id else None
|
|
|
|
def _sync_query():
|
|
"""Synchronous ClickHouse query - run in thread pool
|
|
|
|
IMPORTANT: Creates a NEW client per query to avoid concurrent connection issues.
|
|
The clickhouse_driver Client is NOT thread-safe.
|
|
"""
|
|
import json
|
|
from database.clickhouse import create_clickhouse_client
|
|
|
|
client = create_clickhouse_client()
|
|
if not client:
|
|
return None
|
|
|
|
# Calculate time range
|
|
time_map = {
|
|
"1h": "1 HOUR",
|
|
"6h": "6 HOUR",
|
|
"24h": "24 HOUR",
|
|
"7d": "7 DAY"
|
|
}
|
|
interval = time_map.get(time_range, "24 HOUR")
|
|
|
|
# Build WHERE clause
|
|
where_parts = [
|
|
"change_type = 'error_anomaly'",
|
|
f"detected_at >= now() - INTERVAL {interval}",
|
|
f"cluster_id = {cluster_id}"
|
|
]
|
|
if _resolved_ch_aid:
|
|
where_parts.append(f"analysis_id = '{_resolved_ch_aid}'")
|
|
|
|
where_clause = " AND ".join(where_parts)
|
|
|
|
# Query error anomalies
|
|
query = f"""
|
|
SELECT
|
|
target_name,
|
|
details,
|
|
risk_level,
|
|
detected_at,
|
|
before_state,
|
|
after_state,
|
|
metadata
|
|
FROM change_events
|
|
WHERE {where_clause}
|
|
ORDER BY detected_at DESC
|
|
LIMIT 100
|
|
"""
|
|
|
|
result = client.execute(query)
|
|
|
|
# Process results
|
|
total_anomalies = len(result) if result else 0
|
|
by_error_type = {}
|
|
affected_connections = []
|
|
|
|
for row in (result or []):
|
|
target_name = row[0] or ""
|
|
details = row[1] or ""
|
|
risk_level = row[2] or "medium"
|
|
detected_at = row[3]
|
|
before_state = row[4] or "{}"
|
|
after_state = row[5] or "{}"
|
|
metadata = row[6] or "{}"
|
|
|
|
# Parse states
|
|
try:
|
|
before = json.loads(before_state) if isinstance(before_state, str) else before_state
|
|
after = json.loads(after_state) if isinstance(after_state, str) else after_state
|
|
meta = json.loads(metadata) if isinstance(metadata, str) else metadata
|
|
except:
|
|
before = {}
|
|
after = {}
|
|
meta = {}
|
|
|
|
# Extract error type
|
|
error_type = after.get("error_type") or meta.get("error_type") or "UNKNOWN"
|
|
by_error_type[error_type] = by_error_type.get(error_type, 0) + 1
|
|
|
|
# Parse source and target from target_name (format: "source → target")
|
|
parts = target_name.split(" → ") if " → " in target_name else [target_name, ""]
|
|
source = parts[0] if len(parts) > 0 else target_name
|
|
target = parts[1] if len(parts) > 1 else ""
|
|
|
|
affected_connections.append({
|
|
"source": source,
|
|
"target": target,
|
|
"error_type": error_type,
|
|
"current_error_count": after.get("error_count", 0),
|
|
"previous_error_count": before.get("error_count", 0),
|
|
"risk_level": risk_level,
|
|
"detected_at": detected_at.isoformat() if hasattr(detected_at, 'isoformat') else str(detected_at)
|
|
})
|
|
|
|
# Calculate trends (last hour vs last 24 hours)
|
|
trend_aid_clause = f"AND analysis_id = '{_resolved_ch_aid}'" if _resolved_ch_aid else ""
|
|
trend_query = f"""
|
|
SELECT
|
|
countIf(detected_at >= now() - INTERVAL 1 HOUR) as last_hour,
|
|
countIf(detected_at >= now() - INTERVAL 24 HOUR) as last_24h
|
|
FROM change_events
|
|
WHERE change_type = 'error_anomaly'
|
|
AND cluster_id = {cluster_id}
|
|
{trend_aid_clause}
|
|
"""
|
|
|
|
trend_result = client.execute(trend_query)
|
|
last_hour = trend_result[0][0] if trend_result else 0
|
|
last_24h = trend_result[0][1] if trend_result else 0
|
|
|
|
# Determine trend direction
|
|
if last_hour > 0 and last_24h > 0:
|
|
hour_rate = last_hour
|
|
day_rate = last_24h / 24
|
|
if hour_rate > day_rate * 1.5:
|
|
trend = "increasing"
|
|
elif hour_rate < day_rate * 0.5:
|
|
trend = "decreasing"
|
|
else:
|
|
trend = "stable"
|
|
else:
|
|
trend = "stable"
|
|
|
|
return {
|
|
"total_anomalies": total_anomalies,
|
|
"by_error_type": by_error_type,
|
|
"affected_connections": affected_connections,
|
|
"trends": {
|
|
"last_hour": last_hour,
|
|
"last_24h": last_24h,
|
|
"trend": trend
|
|
}
|
|
}
|
|
|
|
try:
|
|
# Run synchronous ClickHouse query in thread pool to avoid blocking event loop
|
|
result = await asyncio.to_thread(_sync_query)
|
|
|
|
if result is None:
|
|
return ErrorAnomalySummaryResponse(
|
|
cluster_id=cluster_id,
|
|
analysis_id=analysis_id
|
|
)
|
|
|
|
return ErrorAnomalySummaryResponse(
|
|
total_anomalies=result["total_anomalies"],
|
|
by_error_type=result["by_error_type"],
|
|
affected_connections=result["affected_connections"],
|
|
trends=result["trends"],
|
|
cluster_id=cluster_id,
|
|
analysis_id=analysis_id
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to get error anomaly summary", error=str(e))
|
|
return ErrorAnomalySummaryResponse(
|
|
cluster_id=cluster_id,
|
|
analysis_id=analysis_id
|
|
)
|