Files
2026-03-29 21:41:41 +03:00

2594 lines
103 KiB
Python

"""
Simulation Router - Impact simulation and network policy endpoints
Provides APIs for simulating changes and generating network policies
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from typing import Optional, List
from datetime import datetime
import structlog
import json
import csv
import io
import uuid
from utils.jwt_utils import get_current_user
from services.network_policy_service import get_network_policy_service, NetworkPolicyService
from schemas.simulation import (
NetworkPolicyPreviewRequest,
NetworkPolicyPreviewResponse,
NetworkPolicyGenerateRequest,
NetworkPolicyGenerateResponse,
ImpactSimulationRequest,
ImpactSimulationResponse,
ImpactSimulationExportReport,
ExportMetadata,
SimulationExportData,
ImpactSummary,
AffectedService,
SimulationDetails,
NoDependencyInfo,
ImpactLevel,
ImpactCategory,
DependencyType,
ChangeType,
ChangeTypeCharacteristics,
)
logger = structlog.get_logger(__name__)
router = APIRouter()
def get_service() -> NetworkPolicyService:
"""Dependency provider for NetworkPolicyService"""
return get_network_policy_service()
# =============================================================================
# Helper Functions for Impact Simulation
# =============================================================================
def is_infrastructure_endpoint(name: str, namespace: str) -> bool:
"""
Check if an endpoint is infrastructure (SDN, cluster network) that should be
de-prioritized or filtered from impact reports.
These are typically:
- SDN infrastructure IPs (10.128.x.x - 10.131.x.x with random ports)
- Cluster network IPs that are not actual services
- Health check endpoints
"""
import re
# Infrastructure namespaces
infra_namespaces = {
'sdn-infrastructure', 'cluster-network', 'internal-network',
'openshift-sdn', 'openshift-ovn-kubernetes'
}
if namespace in infra_namespaces:
# Check if it's just an IP (not a real service name)
ip_pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
if re.match(ip_pattern, name):
return True
return False
def is_real_service_dependency(dep: dict) -> bool:
"""
Determine if a dependency is a real service vs infrastructure noise.
Real services:
- Have meaningful names (not just IPs)
- Are in application namespaces
- Use well-known ports
- Have significant request counts
Infrastructure noise:
- Raw IP addresses in sdn-infrastructure namespace
- Random high ports (ephemeral ports > 32000)
- Very low request counts on random ports
"""
name = dep.get("name", "")
namespace = dep.get("namespace", "")
port = dep.get("port", 0)
request_count = dep.get("request_count", 0)
# Always include if it's in an application namespace
app_namespace_patterns = ['internal-', 'prod-', 'dev-', 'staging-', 'test-']
is_app_namespace = any(namespace.startswith(p) for p in app_namespace_patterns)
# Exclude infrastructure IPs with random ports
if is_infrastructure_endpoint(name, namespace):
# Unless it's a well-known port with traffic
well_known_ports = {22, 80, 443, 3306, 5432, 6379, 6443, 8080, 8443, 9090, 9200, 27017}
if port not in well_known_ports:
return False
# Include if it has a real service name (not just an IP)
import re
ip_pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
has_real_name = not re.match(ip_pattern, name)
# Include if it's a well-known port
well_known_ports = {
22, 25, 53, 80, 443, 3306, 5432, 5433, 6379, 6443,
8000, 8080, 8443, 9090, 9200, 9300, 27017, 5672, 15672
}
is_well_known_port = port in well_known_ports
# Include if it has significant traffic
has_significant_traffic = request_count >= 5
# Decision logic
if has_real_name:
return True
if is_well_known_port:
return True
if is_app_namespace and has_significant_traffic:
return True
return False
def extract_target_dependencies(graph_data: dict, target_name: str, target_namespace: str) -> dict:
"""
Extract direct and indirect dependencies for a target from the full graph.
Args:
graph_data: Full graph response from graph-query service with nodes and edges
target_name: Name of the target workload
target_namespace: Namespace of the target workload
Returns:
Dictionary with direct_dependencies, indirect_dependencies, node_matches
"""
if not graph_data:
return {
"direct_dependencies": [],
"indirect_dependencies": [],
"node_matches": 0,
"has_external_connections": False,
"confidence": 0.0
}
nodes = graph_data.get("nodes", [])
edges = graph_data.get("edges", [])
# Build node lookup
node_map = {n.get("id"): n for n in nodes}
# Extract actual workload name from target_name
# target_name can be in format: "{kind}-{namespace}-{name}" (e.g., "deployment-flowfish-prod-frontend")
# or just the workload name (e.g., "frontend")
target_name_lower = target_name.lower()
target_ns_lower = target_namespace.lower()
# Try to extract the actual workload name if target_name is in kind-namespace-name format
workload_name = target_name_lower
kind_prefixes = ['deployment-', 'statefulset-', 'daemonset-', 'replicaset-', 'pod-', 'service-']
for prefix in kind_prefixes:
if target_name_lower.startswith(prefix):
# Remove kind prefix
remaining = target_name_lower[len(prefix):]
# Check if namespace is also in the name
if remaining.startswith(target_ns_lower + '-'):
workload_name = remaining[len(target_ns_lower) + 1:]
elif '-' in remaining:
# Try to find namespace in the remaining string
parts = remaining.split('-')
# Find where namespace ends and workload name begins
for i in range(len(parts)):
potential_ns = '-'.join(parts[:i+1])
if potential_ns == target_ns_lower:
workload_name = '-'.join(parts[i+1:])
break
break
logger.info(f"Target matching: original='{target_name}', extracted_workload='{workload_name}', namespace='{target_namespace}'")
# Find target node(s) - match by name containing target_name and namespace
target_ids = set()
for node in nodes:
node_name = node.get("name", "").lower()
node_ns = node.get("namespace", "").lower()
# Check namespace match first
ns_match = (target_ns_lower == node_ns or target_ns_lower in node_ns or node_ns in target_ns_lower)
if not ns_match:
continue
# Match strategies:
# 1. Exact workload name match (node starts with workload name)
# 2. Original target_name in node_name or vice versa
# 3. Workload name in node_name
if node_name.startswith(workload_name + '-') or node_name == workload_name:
target_ids.add(node.get("id"))
elif target_name_lower in node_name or node_name in target_name_lower:
target_ids.add(node.get("id"))
elif workload_name in node_name:
target_ids.add(node.get("id"))
if not target_ids:
# Fallback: try matching just by workload name across all namespaces
for node in nodes:
node_name = node.get("name", "").lower()
if node_name.startswith(workload_name + '-') or workload_name in node_name:
target_ids.add(node.get("id"))
logger.info(f"Found {len(target_ids)} target nodes for {target_namespace}/{target_name} (workload: {workload_name})")
# Find direct dependencies (1-hop)
direct_deps = []
direct_ids = set()
for edge in edges:
source_id = edge.get("source_id") or edge.get("source", {}).get("id")
target_id = edge.get("target_id") or edge.get("target", {}).get("id")
# Check if this edge connects to target
if source_id in target_ids and target_id not in target_ids:
dep_node = node_map.get(target_id, {})
direct_deps.append({
"id": target_id,
"name": dep_node.get("name", "unknown"),
"namespace": dep_node.get("namespace", "default"),
"kind": dep_node.get("kind", "Pod"),
"port": edge.get("port", 0),
"protocol": edge.get("protocol", "TCP"),
"request_count": edge.get("request_count", 0),
"direction": "outgoing"
})
direct_ids.add(target_id)
elif target_id in target_ids and source_id not in target_ids:
dep_node = node_map.get(source_id, {})
direct_deps.append({
"id": source_id,
"name": dep_node.get("name", "unknown"),
"namespace": dep_node.get("namespace", "default"),
"kind": dep_node.get("kind", "Pod"),
"port": edge.get("port", 0),
"protocol": edge.get("protocol", "TCP"),
"request_count": edge.get("request_count", 0),
"direction": "incoming"
})
direct_ids.add(source_id)
# Find indirect dependencies (2-hop)
indirect_deps = []
for edge in edges:
source_id = edge.get("source_id") or edge.get("source", {}).get("id")
target_id = edge.get("target_id") or edge.get("target", {}).get("id")
# Check if this edge connects from a direct dependency to something else
if source_id in direct_ids and target_id not in target_ids and target_id not in direct_ids:
dep_node = node_map.get(target_id, {})
indirect_deps.append({
"id": target_id,
"name": dep_node.get("name", "unknown"),
"namespace": dep_node.get("namespace", "default"),
"kind": dep_node.get("kind", "Pod"),
"port": edge.get("port", 0),
"protocol": edge.get("protocol", "TCP"),
"request_count": edge.get("request_count", 0)
})
elif target_id in direct_ids and source_id not in target_ids and source_id not in direct_ids:
dep_node = node_map.get(source_id, {})
indirect_deps.append({
"id": source_id,
"name": dep_node.get("name", "unknown"),
"namespace": dep_node.get("namespace", "default"),
"kind": dep_node.get("kind", "Pod"),
"port": edge.get("port", 0),
"protocol": edge.get("protocol", "TCP"),
"request_count": edge.get("request_count", 0)
})
# Deduplicate and filter infrastructure noise
seen_direct = set()
unique_direct = []
filtered_infra_count = 0
for d in direct_deps:
key = f"{d['namespace']}/{d['name']}"
if key not in seen_direct:
seen_direct.add(key)
# Filter out infrastructure noise but keep real services
if is_real_service_dependency(d):
unique_direct.append(d)
else:
filtered_infra_count += 1
seen_indirect = set()
unique_indirect = []
for d in indirect_deps:
key = f"{d['namespace']}/{d['name']}"
if key not in seen_indirect and key not in seen_direct:
seen_indirect.add(key)
if is_real_service_dependency(d):
unique_indirect.append(d)
else:
filtered_infra_count += 1
if filtered_infra_count > 0:
logger.info(f"Filtered {filtered_infra_count} infrastructure endpoints from impact report")
# Check for external connections
has_external = any(
d.get("namespace") in ["external"] or "." in d.get("name", "")
for d in unique_direct + unique_indirect
)
# Sort by importance: real services first, then by request count
unique_direct.sort(key=lambda x: (-x.get("request_count", 0)))
unique_indirect.sort(key=lambda x: (-x.get("request_count", 0)))
return {
"direct_dependencies": unique_direct,
"indirect_dependencies": unique_indirect,
"node_matches": len(target_ids),
"has_external_connections": has_external,
"confidence": min(1.0, 0.5 + (len(unique_direct) * 0.1)) if unique_direct else 0.3,
"filtered_infrastructure_count": filtered_infra_count
}
def classify_endpoint_kind(name: str, namespace: str, original_kind: str) -> str:
"""
Properly classify the kind of an endpoint based on its name and namespace.
Returns:
- "Pod" for actual Kubernetes pods (e.g., backend-7686dccc6b-x8bqm)
- "Service" for Kubernetes services
- "ExternalIP" for external IP addresses (10.180.x.x, 192.168.x.x)
- "ClusterIP" for cluster-internal IPs (10.128.x.x, 10.129.x.x, 10.130.x.x, 10.131.x.x)
- "ExternalDNS" for external DNS names (*.bank, api.*, etc.)
- "ClusterService" for internal cluster services (*.svc.cluster.local)
- "Localhost" for localhost
"""
import re
# Check for localhost first
if name.lower() == 'localhost' or name == '127.0.0.1':
return "Localhost"
# Check if it's an IP address
ip_pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
if re.match(ip_pattern, name):
# Parse IP to determine type
parts = name.split('.')
first_octet = int(parts[0])
second_octet = int(parts[1])
# Kubernetes pod network IPs (typically 10.128.x.x - 10.131.x.x in OpenShift)
if first_octet == 10 and 128 <= second_octet <= 131:
# These are pod IPs within the cluster
if namespace in ['sdn-infrastructure']:
return "SDN-IP"
return "ClusterIP"
# Service network IPs (typically 10.96.x.x or 10.106.x.x)
if first_octet == 10 and (96 <= second_octet <= 111 or 104 <= second_octet <= 111):
return "ServiceIP"
# External/infrastructure IPs
if first_octet == 10 and second_octet == 180:
return "ExternalIP"
if first_octet == 192 and second_octet == 168:
return "ExternalIP"
# Default IP classification based on namespace
if namespace in ['external', 'sdn-infrastructure', 'internal-network', 'cluster-network']:
return "ExternalIP"
return "ClusterIP"
# Check if it's a DNS-style name with IP prefix (e.g., 10-128-22-163.harbor-core...)
ip_dns_pattern = r'^\d+-\d+-\d+-\d+\.'
if re.match(ip_dns_pattern, name):
if '.svc.cluster.local' in name:
return "ClusterService"
return "ClusterIP"
# Check if it's a cluster service DNS name
if '.svc.cluster.local' in name:
return "ClusterService"
# Check if it's an external DNS name
if '.' in name and name.count('.') >= 2:
# Common external DNS patterns
if any(ext in name.lower() for ext in ['.bank', '.com', '.net', '.org', '.io', '.local']):
if '.svc.cluster.local' not in name:
return "ExternalDNS"
# Check if it looks like a pod name (has random suffix like -7686dccc6b-x8bqm)
pod_pattern = r'-[a-z0-9]{6,10}-[a-z0-9]{5}$'
if re.search(pod_pattern, name):
return "Pod"
# Check if it looks like a StatefulSet pod (ends with -0, -1, etc.)
statefulset_pattern = r'-\d+$'
if re.search(statefulset_pattern, name):
return "Pod"
# Default to original kind or Pod
return original_kind if original_kind else "Pod"
def calculate_dynamic_risk_score(
dependency_type: str,
request_count: int,
is_critical_namespace: bool,
port: int,
change_type: ChangeType
) -> tuple:
"""
Calculate a dynamic risk score based on multiple factors.
Returns:
tuple: (risk_score: float, risk_factors: list[str])
Factors:
- Dependency type (direct = higher risk)
- Request count (more traffic = higher risk)
- Critical namespace (system namespaces = higher risk)
- Port (well-known ports = higher risk)
- Change type severity
"""
import math
base_score = 0.3
risk_factors = []
# Dependency type factor
if dependency_type == "direct":
base_score += 0.3
risk_factors.append("Direct dependency")
else:
base_score += 0.1
# Request count factor (logarithmic scale)
if request_count > 0:
traffic_factor = min(0.2, math.log10(request_count + 1) * 0.05)
base_score += traffic_factor
if request_count >= 100:
risk_factors.append(f"High traffic ({request_count} requests)")
elif request_count >= 10:
risk_factors.append(f"Moderate traffic ({request_count} requests)")
# Critical namespace factor
if is_critical_namespace:
base_score += 0.15
risk_factors.append("System namespace")
# Well-known port factor
critical_ports = {
443: "HTTPS",
6443: "Kubernetes API",
8443: "Secure HTTP",
53: "DNS",
5432: "PostgreSQL",
3306: "MySQL",
6379: "Redis",
9090: "Prometheus",
9091: "Prometheus Pushgateway",
8080: "HTTP Alt",
8000: "HTTP Alt",
3000: "Dev Server",
}
if port in critical_ports:
base_score += 0.1
risk_factors.append(f"Critical port ({critical_ports[port]})")
# Change type severity factor
severity_map = {
ChangeType.DELETE: (0.15, "Destructive change"),
ChangeType.SCALE_DOWN: (0.12, "Service unavailability"),
ChangeType.NETWORK_ISOLATE: (0.12, "Network isolation"),
ChangeType.NETWORK_POLICY_APPLY: (0.1, "Traffic filtering"),
ChangeType.PORT_CHANGE: (0.1, "Port configuration change"),
ChangeType.IMAGE_UPDATE: (0.08, "Image update"),
ChangeType.CONFIG_CHANGE: (0.05, "Configuration change"),
ChangeType.RESOURCE_CHANGE: (0.05, "Resource adjustment"),
ChangeType.NETWORK_POLICY_REMOVE: (0.02, "Policy removal"),
}
if change_type in severity_map:
score_add, factor = severity_map[change_type]
base_score += score_add
if score_add >= 0.1:
risk_factors.append(factor)
final_score = min(1.0, max(0.0, round(base_score, 2)))
return final_score, risk_factors
def generate_specific_recommendation(
endpoint_kind: str,
namespace: str,
impact_level: ImpactLevel,
change_type: ChangeType,
request_count: int
) -> str:
"""
Generate specific recommendations based on endpoint characteristics.
"""
recommendations = []
# Kind-specific recommendations
if endpoint_kind == "ExternalIP":
recommendations.append("Verify external IP connectivity requirements")
elif endpoint_kind == "ExternalDNS":
recommendations.append("Check DNS resolution and external service availability")
elif endpoint_kind == "ClusterService":
recommendations.append("Review internal service mesh configuration")
elif endpoint_kind == "Localhost":
recommendations.append("Localhost connections typically indicate local processes")
# Namespace-specific recommendations
if 'openshift-' in namespace:
recommendations.append("⚠️ OpenShift system component - proceed with caution")
elif 'kube-system' in namespace:
recommendations.append("⚠️ Kubernetes system component - may affect cluster stability")
elif namespace == 'default':
recommendations.append("Consider moving workloads out of default namespace")
# Change-type specific recommendations
change_recommendations = {
ChangeType.DELETE: "Ensure graceful shutdown and data backup before deletion",
ChangeType.SCALE_DOWN: "Verify HPA/VPA policies and set up alerts for scale-up",
ChangeType.NETWORK_ISOLATE: "Test network policy in audit mode first",
ChangeType.NETWORK_POLICY_APPLY: "Validate policy rules against observed traffic patterns",
ChangeType.PORT_CHANGE: "Update all client configurations and service discovery",
ChangeType.CONFIG_CHANGE: "Test configuration changes in staging environment",
ChangeType.IMAGE_UPDATE: "Verify image compatibility and rollback strategy",
ChangeType.RESOURCE_CHANGE: "Monitor resource utilization after change",
ChangeType.NETWORK_POLICY_REMOVE: "Review security implications of removing policy",
}
if change_type in change_recommendations:
recommendations.append(change_recommendations[change_type])
# Traffic-based recommendations
if request_count > 100:
recommendations.append(f"High traffic endpoint ({request_count} requests) - consider gradual rollout")
elif request_count == 0:
recommendations.append("No recent traffic observed - verify if endpoint is still in use")
# Impact-level recommendations
if impact_level == ImpactLevel.HIGH:
recommendations.insert(0, "🔴 HIGH IMPACT: Coordinate with dependent teams before proceeding")
elif impact_level == ImpactLevel.MEDIUM:
recommendations.insert(0, "🟠 Monitor for cascading effects after change")
return " | ".join(recommendations[:3]) if recommendations else "Review impact carefully before proceeding"
def deduplicate_services(services: list) -> list:
"""
Remove duplicate services based on normalized endpoint identification.
Keeps the entry with more information (e.g., pod name over IP, pod over DNS).
Priority order (highest to lowest):
1. Pod names (e.g., backend-7686dccc6b-x8bqm) - priority 5
2. StatefulSet pods (e.g., postgresql-0) - priority 5
3. Service names (e.g., backend) - priority 4
4. External DNS (e.g., api.external-service.com) - priority 3
5. DNS names with IP prefix (e.g., 10-128-22-163.harbor-core...) - priority 2
6. IP addresses (e.g., 10.128.16.2) - priority 1
"""
import re
def get_name_priority(name: str) -> int:
"""Return priority score - higher is better"""
# IP address - lowest priority
if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', name):
return 1
# DNS-style with IP prefix (e.g., 10-128-22-163.harbor-core...)
if re.match(r'^\d+-\d+-\d+-\d+\.', name):
return 2
# External DNS (e.g., api.external-service.com)
if '.' in name and name.count('.') >= 2:
return 3
# Pod name with hash suffix
if re.search(r'-[a-z0-9]{5,10}-[a-z0-9]{5}$', name):
return 5
# StatefulSet pod (e.g., postgresql-0)
if re.search(r'-\d+$', name):
return 5
# Service name (no suffix)
return 4
def extract_service_identifier(name: str, namespace: str, port: int) -> str:
"""Extract the base service name for deduplication"""
import re
# Handle DNS-style names like 10-128-22-163.harbor-core.prod-harbor-ha.svc.cluster.local
if '.svc.cluster.local' in name:
parts = name.split('.')
if len(parts) >= 2:
# Extract service name and namespace from DNS
service_name = parts[1]
dns_namespace = parts[2] if len(parts) > 2 else namespace
return f"svc:{service_name}:{dns_namespace}:{port}"
# Handle external DNS - keep unique by hostname
if '.' in name and any(ext in name.lower() for ext in ['.bank', '.com', '.net', '.org', '.io']):
return f"dns:{name}:{port}"
# Handle IP addresses
if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', name):
# For cluster IPs (10.128-131.x.x), try to group by service
parts = name.split('.')
if parts[0] == '10' and 128 <= int(parts[1]) <= 131:
# These are likely pod IPs - group by namespace and port
# They'll be deduplicated with their pod names
return f"podip:{namespace}:{port}"
# External IPs - keep as unique
return f"extip:{name}:{port}"
# Handle DNS with IP prefix (e.g., 10-128-22-163.harbor-core...)
if re.match(r'^\d+-\d+-\d+-\d+\.', name):
parts = name.split('.')
if len(parts) >= 2:
service_name = parts[1]
dns_namespace = parts[2] if len(parts) > 2 else namespace
return f"svc:{service_name}:{dns_namespace}:{port}"
# Handle localhost
if name.lower() == 'localhost':
return f"localhost:{port}"
# Handle pod names - extract base service name
base_name = re.sub(r'-[a-z0-9]{5,10}-[a-z0-9]{5}$', '', name)
base_name = re.sub(r'-\d+$', '', base_name) # Remove StatefulSet index
return f"pod:{base_name}:{namespace}:{port}"
seen = {}
for svc in services:
name = svc.get("name", "")
namespace = svc.get("namespace", "")
port = svc.get("connection_details", {}).get("port", 0)
# Create a normalized key
service_id = extract_service_identifier(name, namespace, port)
current_priority = get_name_priority(name)
if service_id in seen:
existing = seen[service_id]
existing_priority = get_name_priority(existing.get("name", ""))
# Keep the one with higher priority (better name)
if current_priority > existing_priority:
seen[service_id] = svc
# If same priority, keep the one with more request count
elif current_priority == existing_priority:
current_count = svc.get("connection_details", {}).get("request_count", 0)
existing_count = existing.get("connection_details", {}).get("request_count", 0)
if current_count > existing_count:
seen[service_id] = svc
else:
seen[service_id] = svc
return list(seen.values())
def filter_indirect_dependencies(
indirect_deps: list,
direct_deps: list,
change_type: ChangeType,
max_indirect: int = 50
) -> list:
"""
Filter and limit indirect dependencies to meaningful ones.
Rules:
- Exclude infrastructure services (ingress, DNS, monitoring) unless directly relevant
- Remove duplicate entries (same service via different paths)
- Limit total indirect dependencies to max_indirect
- Prioritize by request count and criticality
"""
import re
# Get direct dependency names for reference
direct_names = {d.get("name") for d in direct_deps}
# Infrastructure namespaces to de-prioritize for most changes
infra_namespaces = [
'openshift-ingress', 'openshift-ingress-canary', 'openshift-dns',
'openshift-monitoring', 'openshift-network-diagnostics',
'kube-system', 'openshift-operators', 'openshift-authentication',
'openshift-console', 'openshift-ovn-kubernetes', 'openshift-migration'
]
# For network policy changes, infrastructure IS relevant
if change_type in [ChangeType.NETWORK_ISOLATE, ChangeType.NETWORK_POLICY_APPLY]:
infra_namespaces = [] # Don't filter infrastructure
# First pass: deduplicate by base service name
seen_services = {}
def get_base_service_name(name: str, namespace: str) -> str:
"""Extract base service name for deduplication"""
# Handle DNS-style names
if '.svc.cluster.local' in name:
parts = name.split('.')
return f"{parts[1]}:{parts[2] if len(parts) > 2 else namespace}"
# Handle IP-prefixed DNS
if re.match(r'^\d+-\d+-\d+-\d+\.', name):
parts = name.split('.')
if len(parts) >= 2:
return f"{parts[1]}:{parts[2] if len(parts) > 2 else namespace}"
# Handle IP addresses - group by namespace
if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', name):
return f"ip:{namespace}"
# Handle pod names
base = re.sub(r'-[a-z0-9]{5,10}-[a-z0-9]{5}$', '', name)
base = re.sub(r'-\d+$', '', base)
return f"{base}:{namespace}"
for dep in indirect_deps:
name = dep.get("name", "")
namespace = dep.get("namespace", "")
# Skip if it's actually a direct dependency
if name in direct_names:
continue
# Skip localhost and loopback
if name.lower() == 'localhost' or name == '127.0.0.1':
continue
base_key = get_base_service_name(name, namespace)
if base_key in seen_services:
# Keep the one with higher request count
existing = seen_services[base_key]
if dep.get("request_count", 0) > existing.get("request_count", 0):
seen_services[base_key] = dep
else:
seen_services[base_key] = dep
# Second pass: separate infrastructure from application dependencies
filtered = []
infra_deps = []
for dep in seen_services.values():
namespace = dep.get("namespace", "")
is_infra = any(ns in namespace for ns in infra_namespaces)
if is_infra:
infra_deps.append(dep)
else:
filtered.append(dep)
# Sort by request count (descending) to prioritize high-traffic dependencies
filtered.sort(key=lambda x: x.get("request_count", 0), reverse=True)
# Limit application dependencies
app_limit = min(len(filtered), max_indirect - 5) # Reserve 5 slots for infra
result = filtered[:app_limit]
# Add a few infrastructure deps if we have room
remaining_slots = max_indirect - len(result)
if remaining_slots > 0 and infra_deps:
infra_deps.sort(key=lambda x: x.get("request_count", 0), reverse=True)
result.extend(infra_deps[:remaining_slots])
return result
# =============================================================================
# Network Policy Endpoints
# =============================================================================
@router.post(
"/network-policy/generate",
response_model=NetworkPolicyGenerateResponse,
summary="Generate Network Policy",
description="Generate a Kubernetes NetworkPolicy based on observed traffic patterns. "
"Creates a least-privilege policy that allows only observed connections."
)
async def generate_network_policy(
request: NetworkPolicyGenerateRequest,
current_user: dict = Depends(get_current_user),
service: NetworkPolicyService = Depends(get_service)
):
"""
Generate a NetworkPolicy YAML based on observed traffic.
The generated policy will:
- Allow only traffic that was observed during the analysis period
- Include DNS egress rules by default
- Use namespace and pod selectors for internal traffic
- Use IP blocks for external traffic
"""
try:
logger.info(
"Network policy generation requested",
user=current_user.get("username"),
cluster_id=request.cluster_id,
target=f"{request.target_namespace}/{request.target_workload}"
)
response = await service.generate_network_policy(request)
logger.info(
"Network policy generated successfully",
policy_name=response.policy_name,
ingress_sources=response.observed_ingress_sources,
egress_destinations=response.observed_egress_destinations
)
return response
except Exception as e:
logger.error("Network policy generation failed", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to generate network policy: {str(e)}"
)
@router.post(
"/network-policy/preview",
response_model=NetworkPolicyPreviewResponse,
summary="Preview Network Policy Impact",
description="Preview which existing connections would be affected by applying a network policy."
)
async def preview_network_policy_impact(
request: NetworkPolicyPreviewRequest,
current_user: dict = Depends(get_current_user),
service: NetworkPolicyService = Depends(get_service)
):
"""
Preview the impact of applying a network policy.
Returns:
- List of connections that would be blocked
- List of connections that would be allowed
- Warnings and recommendations
"""
try:
logger.info(
"Network policy preview requested",
user=current_user.get("username"),
cluster_id=request.cluster_id,
target=f"{request.target_namespace}/{request.target_workload}"
)
response = await service.preview_network_policy_impact(request)
logger.info(
"Network policy preview completed",
policy_name=response.policy_name,
blocked=response.blocked_connections,
allowed=response.allowed_connections
)
return response
except Exception as e:
logger.error("Network policy preview failed", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to preview network policy: {str(e)}"
)
# =============================================================================
# Impact Simulation Endpoints
# =============================================================================
@router.post(
"/impact",
response_model=ImpactSimulationResponse,
summary="Run Impact Simulation",
description="Simulate the impact of a change on the target resource and its dependencies."
)
async def run_impact_simulation(
request: ImpactSimulationRequest,
current_user: dict = Depends(get_current_user),
service: NetworkPolicyService = Depends(get_service)
):
"""
Run an impact simulation for a proposed change.
Analyzes:
- Direct dependencies (1-hop)
- Indirect dependencies (2-hop)
- Impact severity for each affected service
- Recommendations for safe change execution
"""
try:
logger.info(
"Impact simulation requested",
user=current_user.get("username"),
cluster_id=request.cluster_id,
analysis_id=request.analysis_id,
target=f"{request.target_namespace}/{request.target_name}",
change_type=request.change_type.value
)
# ========== DEBUG: IMPACT SIMULATION START ==========
logger.warning("IMPACT_SIM_START",
user=current_user.get('username'),
cluster_id=request.cluster_id,
analysis_id=request.analysis_id,
target=f"{request.target_namespace}/{request.target_name}",
target_kind=request.target_kind,
change_type=request.change_type.value
)
# Use graph-query service (same as communications endpoint) for dependency data
# This ensures we use the same data source that powers the visualization
from routers.communications import graph_query_client
# Get full dependency graph from graph-query service
logger.warning("IMPACT_SIM_GRAPH_FETCH: fetching graph from service")
full_graph = await graph_query_client.get_dependency_graph(
cluster_id=request.cluster_id,
analysis_id=request.analysis_id,
namespace=None, # Get all namespaces to find dependencies
depth=2,
search=request.target_name # Filter to target and its connections
)
# DEBUG: Log full graph summary
nodes_count = len(full_graph.get("nodes", [])) if full_graph else 0
edges_count = len(full_graph.get("edges", [])) if full_graph else 0
namespaces_in_graph = []
sample_nodes = []
if full_graph and full_graph.get("nodes"):
namespaces_in_graph = sorted(set(n.get("namespace", "unknown") for n in full_graph["nodes"]))
sample_nodes = [f"{n.get('namespace', '?')}/{n.get('name', '?')}" for n in full_graph["nodes"][:10]]
logger.warning("IMPACT_SIM_GRAPH_RESPONSE: graph data received",
total_nodes=nodes_count,
total_edges=edges_count,
namespaces=namespaces_in_graph,
sample_nodes=sample_nodes
)
# Extract dependencies for the target from the full graph
graph_data = extract_target_dependencies(
full_graph,
request.target_name,
request.target_namespace
)
# DEBUG: Log extracted dependencies
direct_deps_summary = [
f"{d.get('namespace', '?')}/{d.get('name', '?')}:{d.get('port', '?')}"
for d in graph_data.get("direct_dependencies", [])[:10]
]
indirect_deps_summary = [
f"{d.get('namespace', '?')}/{d.get('name', '?')}:{d.get('port', '?')}"
for d in graph_data.get("indirect_dependencies", [])[:5]
]
logger.warning("IMPACT_SIM_DEPENDENCIES: dependencies extracted",
target=f"{request.target_namespace}/{request.target_name}",
node_matches=graph_data.get('node_matches', 0),
direct_count=len(graph_data.get('direct_dependencies', [])),
indirect_count=len(graph_data.get('indirect_dependencies', [])),
has_external=graph_data.get('has_external_connections', False),
filtered_infra=graph_data.get('filtered_infrastructure_count', 0),
direct_deps=direct_deps_summary,
indirect_deps=indirect_deps_summary
)
logger.info(
"Graph data extracted",
direct_count=len(graph_data.get("direct_dependencies", [])),
indirect_count=len(graph_data.get("indirect_dependencies", [])),
node_matches=graph_data.get("node_matches", 0)
)
# Get change type characteristics
change_chars = ChangeTypeCharacteristics.get(request.change_type)
# Calculate affected services with change-type aware impact
affected_services = []
direct_deps = graph_data.get("direct_dependencies", [])
indirect_deps_raw = graph_data.get("indirect_dependencies", [])
# Filter and limit indirect dependencies to prevent explosion
indirect_deps = filter_indirect_dependencies(
indirect_deps_raw,
direct_deps,
request.change_type,
max_indirect=50 # Reasonable limit
)
# Process direct dependencies
for dep in direct_deps:
dep_name = dep.get("name", "unknown")
dep_namespace = dep.get("namespace", "default")
dep_port = dep.get("port", 0)
request_count = dep.get("request_count", 0)
# Properly classify the endpoint kind
original_kind = dep.get("kind", "Pod")
endpoint_kind = classify_endpoint_kind(dep_name, dep_namespace, original_kind)
# Use new change-type aware calculation
impact, impact_category, impact_desc, risk_factors = service.calculate_impact_for_change_type(
request.change_type,
DependencyType.DIRECT,
request_count,
dep.get("is_critical", False)
)
# Skip if this change type doesn't affect direct dependencies
if impact == ImpactLevel.NONE and not change_chars["affects_direct"]:
continue
# Downgrade impact for infrastructure endpoints (SDN-IP, ExternalIP with random ports)
is_infra_endpoint = endpoint_kind in ['SDN-IP', 'ServiceIP'] or \
(endpoint_kind == 'ExternalIP' and dep_port > 32000)
if is_infra_endpoint and impact == ImpactLevel.HIGH:
# Infrastructure endpoints are less critical for application impact
impact = ImpactLevel.MEDIUM
impact_desc = f"Infrastructure connectivity may be affected. {impact_desc}"
# Calculate dynamic risk score and risk factors
risk_score, calculated_risk_factors = calculate_dynamic_risk_score(
"direct",
request_count,
'openshift-' in dep_namespace or 'kube-system' in dep_namespace,
dep_port,
request.change_type
)
# Reduce risk score for infrastructure endpoints
if is_infra_endpoint:
risk_score = min(risk_score, 0.5)
# Merge calculated risk factors with change-type risk factors
all_risk_factors = list(set(risk_factors + calculated_risk_factors))
# Generate specific recommendation
recommendation = generate_specific_recommendation(
endpoint_kind,
dep_namespace,
impact,
request.change_type,
request_count
)
affected_services.append(AffectedService(
id=dep.get("id", str(uuid.uuid4())),
name=dep_name,
namespace=dep_namespace,
kind=endpoint_kind,
impact=impact,
impact_category=impact_category,
impact_description=impact_desc,
dependency=DependencyType.DIRECT,
recommendation=recommendation,
connection_details={
"protocol": dep.get("protocol", "TCP"),
"port": dep_port,
"request_count": request_count,
"last_seen": dep.get("last_seen")
},
risk_score=risk_score,
risk_factors=all_risk_factors,
recovery_info={
"recovery_time": change_chars["recovery_time"],
"reversible": change_chars["reversible"]
}
))
# Process indirect dependencies
for dep in indirect_deps:
dep_name = dep.get("name", "unknown")
dep_namespace = dep.get("namespace", "default")
dep_port = dep.get("port", 0)
request_count = dep.get("request_count", 0)
hop_distance = dep.get("hop_distance", 2)
# Properly classify the endpoint kind
original_kind = dep.get("kind", "Pod")
endpoint_kind = classify_endpoint_kind(dep_name, dep_namespace, original_kind)
# Use new change-type aware calculation
impact, impact_category, impact_desc, risk_factors = service.calculate_impact_for_change_type(
request.change_type,
DependencyType.INDIRECT,
request_count,
False
)
# Skip if this change type doesn't affect indirect dependencies
if impact == ImpactLevel.NONE and not change_chars["affects_indirect"]:
continue
# Downgrade impact for infrastructure endpoints
is_infra_endpoint = endpoint_kind in ['SDN-IP', 'ServiceIP'] or \
(endpoint_kind == 'ExternalIP' and dep_port > 32000)
if is_infra_endpoint and impact in [ImpactLevel.HIGH, ImpactLevel.MEDIUM]:
impact = ImpactLevel.LOW
impact_desc = f"Infrastructure connectivity may be affected. {impact_desc}"
# Calculate dynamic risk score (reduced for indirect)
risk_score, calculated_risk_factors = calculate_dynamic_risk_score(
"indirect",
request_count,
'openshift-' in dep_namespace or 'kube-system' in dep_namespace,
dep_port,
request.change_type
)
risk_score = risk_score * 0.7 # 30% reduction for indirect
# Further reduce risk score for infrastructure endpoints
if is_infra_endpoint:
risk_score = min(risk_score, 0.3)
# Merge calculated risk factors with change-type risk factors
all_risk_factors = list(set(risk_factors + calculated_risk_factors))
# Generate specific recommendation
recommendation = generate_specific_recommendation(
endpoint_kind,
dep_namespace,
impact,
request.change_type,
request_count
)
affected_services.append(AffectedService(
id=dep.get("id", str(uuid.uuid4())),
name=dep_name,
namespace=dep_namespace,
kind=endpoint_kind,
impact=impact,
impact_category=impact_category,
impact_description=impact_desc,
dependency=DependencyType.INDIRECT,
recommendation=recommendation,
connection_details={
"protocol": dep.get("protocol", "TCP"),
"port": dep_port,
"request_count": request_count,
"hop_distance": hop_distance
},
risk_score=round(risk_score, 2),
risk_factors=all_risk_factors,
recovery_info={
"recovery_time": change_chars["recovery_time"],
"reversible": change_chars["reversible"]
}
))
# Deduplicate services (remove duplicate IPs that map to same service)
affected_services_dicts = [
{
"id": s.id,
"name": s.name,
"namespace": s.namespace,
"kind": s.kind,
"impact": s.impact,
"impact_category": s.impact_category,
"impact_description": s.impact_description,
"dependency": s.dependency,
"recommendation": s.recommendation,
"connection_details": s.connection_details,
"risk_score": s.risk_score,
"risk_factors": s.risk_factors,
"recovery_info": s.recovery_info
}
for s in affected_services
]
deduped_dicts = deduplicate_services(affected_services_dicts)
# Rebuild affected_services from deduplicated list
affected_services = [
AffectedService(
id=d["id"],
name=d["name"],
namespace=d["namespace"],
kind=d["kind"],
impact=d["impact"],
impact_category=d["impact_category"],
impact_description=d["impact_description"],
dependency=d["dependency"],
recommendation=d["recommendation"],
connection_details=d["connection_details"],
risk_score=d["risk_score"],
risk_factors=d["risk_factors"],
recovery_info=d["recovery_info"]
)
for d in deduped_dicts
]
# Calculate summary with change-type specific information
high_count = sum(1 for s in affected_services if s.impact == ImpactLevel.HIGH)
medium_count = sum(1 for s in affected_services if s.impact == ImpactLevel.MEDIUM)
low_count = sum(1 for s in affected_services if s.impact == ImpactLevel.LOW)
# Generate expected behavior description based on change type
expected_behaviors = {
ChangeType.DELETE: f"Service will be completely removed. {high_count} services will lose connectivity immediately.",
ChangeType.SCALE_DOWN: f"Service will be unavailable until scaled back. {high_count} services will experience connection failures.",
ChangeType.NETWORK_ISOLATE: f"Network traffic will be blocked. {high_count} direct connections will be severed.",
ChangeType.RESOURCE_CHANGE: f"Performance may degrade. {medium_count} services may experience slower responses.",
ChangeType.PORT_CHANGE: f"Port change will break existing connections. {high_count} clients need configuration update.",
ChangeType.CONFIG_CHANGE: f"Behavior may change. {medium_count} services may need to adapt to new configuration.",
ChangeType.IMAGE_UPDATE: f"Brief disruption during rollout. {medium_count} services may experience temporary errors.",
ChangeType.NETWORK_POLICY_APPLY: f"Traffic filtering active. {high_count} connections may be blocked if not in allow list.",
ChangeType.NETWORK_POLICY_REMOVE: f"Security policy removed. No connectivity impact, but security posture changed.",
}
summary = ImpactSummary(
total_affected=len(affected_services),
high_impact=high_count,
medium_impact=medium_count,
low_impact=low_count,
blast_radius=len(direct_deps) + len(indirect_deps),
confidence_score=min(1.0, graph_data.get("confidence", 0.8)),
# Change-type specific fields
primary_impact_category=change_chars["primary_impact"],
impact_description=change_chars["description"],
expected_behavior=expected_behaviors.get(request.change_type, "Review impact carefully."),
recovery_time=change_chars["recovery_time"],
is_reversible=change_chars["reversible"]
)
# Determine no-dependency scenario if applicable
no_dep_info = None
if len(affected_services) == 0:
graph_matches = graph_data.get("node_matches", 0)
has_external = graph_data.get("has_external_connections", False)
no_dep_info = service.determine_no_dependency_scenario(
graph_matches, has_external, request.target_kind
)
# Generate network policy suggestion for network-related changes
network_policy_suggestion = None
if request.change_type in [ChangeType.NETWORK_ISOLATE, ChangeType.NETWORK_POLICY_APPLY]:
try:
policy_request = NetworkPolicyGenerateRequest(
cluster_id=request.cluster_id,
analysis_id=request.analysis_id,
target_namespace=request.target_namespace,
target_workload=request.target_name,
target_kind=request.target_kind,
policy_types=[],
include_dns=True,
strict_mode=False
)
network_policy_suggestion = await service.generate_network_policy(policy_request)
except Exception as e:
logger.warning("Failed to generate network policy suggestion", error=str(e))
# Build simulation details
change_descriptions = {
ChangeType.DELETE: "Complete removal of the resource from the cluster",
ChangeType.SCALE_DOWN: "Scale deployment replicas to zero",
ChangeType.NETWORK_ISOLATE: "Apply network policy to isolate the target",
ChangeType.RESOURCE_CHANGE: "Modify CPU/Memory resource limits",
ChangeType.PORT_CHANGE: "Change exposed service ports",
ChangeType.CONFIG_CHANGE: "Modify ConfigMap/Secret/Environment variables",
ChangeType.IMAGE_UPDATE: "Update container image version",
ChangeType.NETWORK_POLICY_APPLY: "Apply a new network policy",
ChangeType.NETWORK_POLICY_REMOVE: "Remove an existing network policy"
}
details = SimulationDetails(
target_name=request.target_name,
target_namespace=request.target_namespace,
target_kind=request.target_kind,
change_type=request.change_type.value,
change_description=change_descriptions.get(request.change_type, "Unknown change type"),
graph_matches=graph_data.get("node_matches", 0),
simulation_timestamp=datetime.utcnow()
)
# Build timeline projection based on change type characteristics
timeline_descriptions = {
ChangeType.DELETE: {
"immediate": "Services lose connectivity immediately upon deletion",
"short_term": "Dependent services may fail health checks and restart",
"long_term": "System stabilizes after failover or manual intervention"
},
ChangeType.SCALE_DOWN: {
"immediate": "Existing connections drain, new connections fail",
"short_term": "Queued requests may timeout, clients retry",
"long_term": "System stable once scaled back up"
},
ChangeType.NETWORK_ISOLATE: {
"immediate": "Network traffic blocked, connections timeout",
"short_term": "Health checks may fail, pods may restart",
"long_term": "System adapts to new network topology"
},
ChangeType.RESOURCE_CHANGE: {
"immediate": "Pod may restart if limits reduced significantly",
"short_term": "Response times may increase, throttling possible",
"long_term": "System adapts to new resource allocation"
},
ChangeType.PORT_CHANGE: {
"immediate": "Connections to old port fail immediately",
"short_term": "Clients with cached port info continue failing",
"long_term": "Stable after all clients update configuration"
},
ChangeType.CONFIG_CHANGE: {
"immediate": "New config takes effect (may require pod restart)",
"short_term": "Behavior changes propagate through system",
"long_term": "System operates with new configuration"
},
ChangeType.IMAGE_UPDATE: {
"immediate": "Rolling update begins, brief connection disruption",
"short_term": "Old and new versions may coexist during rollout",
"long_term": "All pods running new version, system stable"
},
ChangeType.NETWORK_POLICY_APPLY: {
"immediate": "Policy enforced, non-matching traffic blocked",
"short_term": "Blocked services may fail health checks",
"long_term": "System operates within policy constraints"
},
ChangeType.NETWORK_POLICY_REMOVE: {
"immediate": "Policy removed, all traffic allowed",
"short_term": "No operational impact expected",
"long_term": "Security posture changed, monitor for anomalies"
},
}
change_timeline = timeline_descriptions.get(request.change_type, {
"immediate": "Immediate effects upon change application",
"short_term": "Short-term cascading effects",
"long_term": "Long-term system adaptation"
})
timeline_projection = {
"immediate": {
"description": change_timeline["immediate"],
"affected_count": high_count,
"expected_duration": "0-5 minutes",
"impact_category": change_chars["primary_impact"].value if hasattr(change_chars["primary_impact"], 'value') else str(change_chars["primary_impact"])
},
"short_term": {
"description": change_timeline["short_term"],
"affected_count": medium_count,
"expected_duration": "5-30 minutes",
"secondary_impacts": [imp.value if hasattr(imp, 'value') else str(imp) for imp in change_chars["secondary_impacts"]]
},
"long_term": {
"description": change_timeline["long_term"],
"affected_count": low_count,
"expected_duration": "30+ minutes",
"recovery_time": change_chars["recovery_time"]
}
}
# Build rollback scenario based on change type
rollback_steps = {
ChangeType.DELETE: [
"Restore from backup or redeploy from manifest",
"Verify all ConfigMaps and Secrets are present",
"Wait for pods to become ready",
"Verify connectivity from dependent services"
],
ChangeType.SCALE_DOWN: [
"Scale deployment back to original replica count",
"Wait for pods to become ready",
"Verify load balancing is working",
"Check for any queued request backlog"
],
ChangeType.NETWORK_ISOLATE: [
"Remove or modify network policy",
"Verify traffic flow is restored",
"Check health checks are passing",
"Monitor for connection timeouts"
],
ChangeType.RESOURCE_CHANGE: [
"Revert resource limits to previous values",
"Pod will restart with new limits",
"Monitor resource utilization",
"Verify performance is restored"
],
ChangeType.PORT_CHANGE: [
"Revert port configuration",
"Update service selector if needed",
"Notify dependent teams of port reversion",
"Verify client connections restored"
],
ChangeType.CONFIG_CHANGE: [
"Revert ConfigMap/Secret to previous version",
"Restart pods to pick up old config",
"Verify application behavior",
"Check for cached configuration issues"
],
ChangeType.IMAGE_UPDATE: [
"Rollback deployment to previous revision",
"kubectl rollout undo deployment/<name>",
"Wait for rollout to complete",
"Verify application functionality"
],
ChangeType.NETWORK_POLICY_APPLY: [
"Delete the applied network policy",
"Verify traffic flow is restored",
"Review security implications",
"Update documentation"
],
ChangeType.NETWORK_POLICY_REMOVE: [
"Reapply the network policy",
"Verify policy is enforced",
"Check no legitimate traffic is blocked",
"Update security documentation"
],
}
rollback_risks = {
ChangeType.DELETE: ["Data loss if not backed up", "State inconsistency", "Dependency ordering issues"],
ChangeType.SCALE_DOWN: ["Request queue overflow", "Brief unavailability during scale-up"],
ChangeType.NETWORK_ISOLATE: ["Security exposure during rollback"],
ChangeType.RESOURCE_CHANGE: ["Pod restart required", "Brief unavailability"],
ChangeType.PORT_CHANGE: ["Client configuration sync issues"],
ChangeType.CONFIG_CHANGE: ["Config cache invalidation", "Restart required"],
ChangeType.IMAGE_UPDATE: ["Data migration issues if schema changed"],
ChangeType.NETWORK_POLICY_APPLY: ["Security exposure"],
ChangeType.NETWORK_POLICY_REMOVE: ["Traffic may be blocked again"],
}
rollback_times = {
ChangeType.DELETE: "15-60 minutes (requires redeployment)",
ChangeType.SCALE_DOWN: "1-5 minutes",
ChangeType.NETWORK_ISOLATE: "< 1 minute",
ChangeType.RESOURCE_CHANGE: "1-5 minutes (pod restart)",
ChangeType.PORT_CHANGE: "1-5 minutes + client updates",
ChangeType.CONFIG_CHANGE: "1-5 minutes",
ChangeType.IMAGE_UPDATE: "5-15 minutes",
ChangeType.NETWORK_POLICY_APPLY: "< 1 minute",
ChangeType.NETWORK_POLICY_REMOVE: "< 1 minute",
}
rollback_scenario = {
"feasibility": "high" if change_chars["reversible"] else "medium",
"estimated_time": rollback_times.get(request.change_type, "5-15 minutes"),
"steps": rollback_steps.get(request.change_type, [
"Identify affected services from this simulation",
"Prepare rollback configuration/manifest",
"Execute rollback in reverse order",
"Verify service health and connectivity"
]),
"risks": rollback_risks.get(request.change_type, []),
"reversible": change_chars["reversible"]
}
response = ImpactSimulationResponse(
success=True,
simulation_id=str(uuid.uuid4()),
details=details,
summary=summary,
affected_services=affected_services,
no_dependency_info=no_dep_info,
network_policy_suggestion=network_policy_suggestion,
timeline_projection=timeline_projection,
rollback_scenario=rollback_scenario
)
# ========== DEBUG: IMPACT SIMULATION RESULTS ==========
affected_services_summary = [
{
"name": f"{svc.namespace}/{svc.name}",
"kind": svc.kind,
"impact": svc.impact.value,
"port": svc.connection_details.get('port', 0),
"requests": svc.connection_details.get('request_count', 0),
"risk": svc.risk_score
}
for svc in affected_services[:15]
]
logger.warning("IMPACT_SIM_RESULTS: simulation completed",
simulation_id=response.simulation_id,
total_affected=summary.total_affected,
high_impact=summary.high_impact,
medium_impact=summary.medium_impact,
low_impact=summary.low_impact,
blast_radius=summary.blast_radius,
affected_services=affected_services_summary,
no_dependency_scenario=no_dep_info.scenario if no_dep_info else None
)
# ========== DEBUG END ==========
logger.info(
"Impact simulation completed",
simulation_id=response.simulation_id,
total_affected=summary.total_affected,
high_impact=summary.high_impact
)
# Save simulation to history (non-blocking)
try:
import time
start_time = time.time()
await save_simulation_to_history(
response,
request,
current_user.get('username', 'unknown'),
duration_ms=int((time.time() - start_time) * 1000)
)
except Exception as hist_err:
logger.warning("Failed to save simulation to history", error=str(hist_err))
return response
except Exception as e:
logger.error("Impact simulation failed", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to run impact simulation: {str(e)}"
)
# =============================================================================
# Export Endpoints
# =============================================================================
@router.post(
"/impact/export/json",
summary="Export Impact Simulation to JSON",
description="Export impact simulation results to JSON format."
)
async def export_impact_simulation_json(
request: ImpactSimulationRequest,
cluster_name: Optional[str] = Query(None, description="Cluster name for report"),
current_user: dict = Depends(get_current_user),
service: NetworkPolicyService = Depends(get_service)
):
"""Export impact simulation results to JSON"""
try:
# Run simulation first
simulation_response = await run_impact_simulation(request, current_user, service)
# Build export report
export_report = ImpactSimulationExportReport(
metadata=ExportMetadata(
generated_at=datetime.utcnow(),
analysis_id=request.analysis_id,
cluster_id=request.cluster_id,
cluster_name=cluster_name,
export_format="json"
),
simulation=SimulationExportData(
target_name=request.target_name,
target_namespace=request.target_namespace,
target_kind=request.target_kind,
change_type=request.change_type.value,
graph_matches=simulation_response.details.graph_matches
),
impact_summary=simulation_response.summary,
affected_services=simulation_response.affected_services,
network_policy_suggestion=simulation_response.network_policy_suggestion.model_dump() if simulation_response.network_policy_suggestion else None,
recommendations=[s.recommendation for s in simulation_response.affected_services]
)
# Convert to JSON
output = json.dumps(export_report.model_dump(), indent=2, default=str)
# Generate filename
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
target_safe = request.target_name.replace("/", "-").replace(":", "-")
filename = f"impact_simulation_{target_safe}_{timestamp}.json"
logger.info("Impact simulation exported to JSON", filename=filename)
return StreamingResponse(
iter([output]),
media_type="application/json",
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
)
except HTTPException:
raise
except Exception as e:
logger.error("Impact simulation JSON export failed", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Export failed: {str(e)}"
)
@router.post(
"/impact/export/csv",
summary="Export Impact Simulation to CSV",
description="Export impact simulation results to CSV format."
)
async def export_impact_simulation_csv(
request: ImpactSimulationRequest,
cluster_name: Optional[str] = Query(None, description="Cluster name for report"),
current_user: dict = Depends(get_current_user),
service: NetworkPolicyService = Depends(get_service)
):
"""Export impact simulation results to CSV"""
try:
# Run simulation first
simulation_response = await run_impact_simulation(request, current_user, service)
# Build CSV
output = io.StringIO()
writer = csv.writer(output)
# Metadata section
writer.writerow(["# Impact Simulation Report"])
writer.writerow(["Generated At", datetime.utcnow().isoformat()])
writer.writerow(["Cluster ID", request.cluster_id])
writer.writerow(["Cluster Name", cluster_name or ""])
writer.writerow(["Analysis ID", request.analysis_id or ""])
writer.writerow([])
# Target section
writer.writerow(["# Simulation Target"])
writer.writerow(["Target Name", request.target_name])
writer.writerow(["Target Namespace", request.target_namespace])
writer.writerow(["Target Kind", request.target_kind])
writer.writerow(["Change Type", request.change_type.value])
writer.writerow(["Graph Matches", simulation_response.details.graph_matches])
writer.writerow([])
# Summary section
writer.writerow(["# Impact Summary"])
writer.writerow(["Total Affected", simulation_response.summary.total_affected])
writer.writerow(["High Impact", simulation_response.summary.high_impact])
writer.writerow(["Medium Impact", simulation_response.summary.medium_impact])
writer.writerow(["Low Impact", simulation_response.summary.low_impact])
writer.writerow(["Blast Radius", simulation_response.summary.blast_radius])
writer.writerow(["Confidence Score", simulation_response.summary.confidence_score])
writer.writerow([])
# Affected services section
writer.writerow(["# Affected Services"])
writer.writerow([
"Name", "Namespace", "Kind", "Impact", "Dependency Type",
"Protocol", "Port", "Request Count", "Risk Score", "Recommendation"
])
for svc in simulation_response.affected_services:
conn = svc.connection_details or {}
writer.writerow([
svc.name,
svc.namespace,
svc.kind,
svc.impact.value,
svc.dependency.value,
conn.get("protocol", ""),
conn.get("port", ""),
conn.get("request_count", ""),
svc.risk_score,
svc.recommendation
])
output.seek(0)
# Generate filename
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
target_safe = request.target_name.replace("/", "-").replace(":", "-")
filename = f"impact_simulation_{target_safe}_{timestamp}.csv"
logger.info("Impact simulation exported to CSV", filename=filename)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
)
except HTTPException:
raise
except Exception as e:
logger.error("Impact simulation CSV export failed", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Export failed: {str(e)}"
)
# =============================================================================
# Utility Endpoints
# =============================================================================
@router.get(
"/change-types",
summary="Get Available Change Types",
description="Get list of available change types for simulation."
)
async def get_change_types(
current_user: dict = Depends(get_current_user)
):
"""Get available simulation change types"""
return {
"change_types": [
{
"key": "delete",
"label": "Delete / Remove",
"description": "Completely remove the target from cluster",
"icon": "delete",
"category": "destructive"
},
{
"key": "scale_down",
"label": "Scale Down (replicas: 0)",
"description": "Scale deployment to zero replicas",
"icon": "arrow-down",
"category": "scaling"
},
{
"key": "network_isolate",
"label": "Network Isolation",
"description": "Apply network policy to isolate target",
"icon": "lock",
"category": "network"
},
{
"key": "resource_change",
"label": "Resource Limit Change",
"description": "Modify CPU/Memory limits",
"icon": "dashboard",
"category": "resource"
},
{
"key": "port_change",
"label": "Port Change",
"description": "Change exposed ports",
"icon": "api",
"category": "network"
},
{
"key": "config_change",
"label": "Configuration Change",
"description": "Modify ConfigMap/Secret/Environment",
"icon": "setting",
"category": "configuration"
},
{
"key": "image_update",
"label": "Image Update",
"description": "Update container image version",
"icon": "cloud-upload",
"category": "deployment"
},
{
"key": "network_policy_apply",
"label": "Apply Network Policy",
"description": "Simulate applying a new network policy",
"icon": "safety",
"category": "network",
"advanced": True
},
{
"key": "network_policy_remove",
"label": "Remove Network Policy",
"description": "Simulate removing an existing network policy",
"icon": "unlock",
"category": "network",
"advanced": True
}
]
}
# =============================================================================
# Scheduled Simulations Endpoints
# =============================================================================
from pydantic import BaseModel, Field
from typing import List, Optional as OptionalType
from datetime import datetime as dt
class ScheduledSimulationCreate(BaseModel):
"""Request to create a scheduled simulation"""
name: str = Field(..., description="Name of the scheduled simulation")
description: OptionalType[str] = None
cluster_id: str
analysis_id: OptionalType[str] = None
target_name: str
target_namespace: str
target_kind: str = "Deployment"
change_type: str
schedule_type: str = Field("once", description="once, daily, weekly")
scheduled_time: str = Field(..., description="ISO format datetime")
notify_before_minutes: int = 15
auto_rollback: bool = False
rollback_on_failure: bool = True
class ScheduledSimulationResponse(BaseModel):
"""Response for a scheduled simulation"""
id: str
name: str
description: OptionalType[str] = None
cluster_id: str
analysis_id: OptionalType[str] = None
target_name: str
target_namespace: str
target_kind: str
change_type: str
schedule_type: str
scheduled_time: str
notify_before_minutes: int
auto_rollback: bool
rollback_on_failure: bool
status: str = "scheduled"
created_at: str
created_by: OptionalType[str] = None
last_run_at: OptionalType[str] = None
last_run_result: OptionalType[str] = None
class ScheduledSimulationListResponse(BaseModel):
"""Response for listing scheduled simulations"""
simulations: List[ScheduledSimulationResponse]
total: int
@router.get(
"/scheduled",
response_model=ScheduledSimulationListResponse,
summary="List Scheduled Simulations",
description="Get all scheduled simulations for the current user"
)
async def list_scheduled_simulations(
cluster_id: OptionalType[str] = Query(None, description="Filter by cluster"),
status: OptionalType[str] = Query(None, description="Filter by status"),
current_user: dict = Depends(get_current_user)
):
"""List all scheduled simulations"""
try:
from database.postgresql import database
# Check if table exists
check_query = """
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'scheduled_simulations'
) as exists
"""
exists = await database.fetch_one(check_query)
if not exists or not exists.get('exists'):
# Create table if it doesn't exist
create_table = """
CREATE TABLE IF NOT EXISTS scheduled_simulations (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
cluster_id VARCHAR(50) NOT NULL,
analysis_id VARCHAR(50),
target_name VARCHAR(255) NOT NULL,
target_namespace VARCHAR(255) NOT NULL,
target_kind VARCHAR(50) DEFAULT 'Deployment',
change_type VARCHAR(50) NOT NULL,
schedule_type VARCHAR(20) DEFAULT 'once',
scheduled_time TIMESTAMP NOT NULL,
notify_before_minutes INT DEFAULT 15,
auto_rollback BOOLEAN DEFAULT FALSE,
rollback_on_failure BOOLEAN DEFAULT TRUE,
status VARCHAR(20) DEFAULT 'scheduled',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100),
last_run_at TIMESTAMP,
last_run_result TEXT
)
"""
await database.execute(create_table)
return ScheduledSimulationListResponse(simulations=[], total=0)
# Build query
query = """
SELECT * FROM scheduled_simulations
WHERE 1=1
"""
params = {}
if cluster_id:
query += " AND cluster_id = :cluster_id"
params['cluster_id'] = cluster_id
if status:
query += " AND status = :status"
params['status'] = status
query += " ORDER BY scheduled_time ASC"
rows = await database.fetch_all(query, params)
simulations = [
ScheduledSimulationResponse(
id=row['id'],
name=row['name'],
description=row['description'],
cluster_id=row['cluster_id'],
analysis_id=row['analysis_id'],
target_name=row['target_name'],
target_namespace=row['target_namespace'],
target_kind=row['target_kind'],
change_type=row['change_type'],
schedule_type=row['schedule_type'],
scheduled_time=row['scheduled_time'].isoformat() if row['scheduled_time'] else '',
notify_before_minutes=row['notify_before_minutes'] or 15,
auto_rollback=row['auto_rollback'] or False,
rollback_on_failure=row['rollback_on_failure'] if row['rollback_on_failure'] is not None else True,
status=row['status'],
created_at=row['created_at'].isoformat() if row['created_at'] else '',
created_by=row['created_by'],
last_run_at=row['last_run_at'].isoformat() if row['last_run_at'] else None,
last_run_result=row['last_run_result']
)
for row in rows
]
return ScheduledSimulationListResponse(simulations=simulations, total=len(simulations))
except Exception as e:
logger.error("Failed to list scheduled simulations", error=str(e))
return ScheduledSimulationListResponse(simulations=[], total=0)
@router.post(
"/scheduled",
response_model=ScheduledSimulationResponse,
summary="Create Scheduled Simulation",
description="Create a new scheduled simulation"
)
async def create_scheduled_simulation(
request: ScheduledSimulationCreate,
current_user: dict = Depends(get_current_user)
):
"""Create a new scheduled simulation"""
try:
from database.postgresql import database
# Ensure table exists
create_table = """
CREATE TABLE IF NOT EXISTS scheduled_simulations (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
cluster_id VARCHAR(50) NOT NULL,
analysis_id VARCHAR(50),
target_name VARCHAR(255) NOT NULL,
target_namespace VARCHAR(255) NOT NULL,
target_kind VARCHAR(50) DEFAULT 'Deployment',
change_type VARCHAR(50) NOT NULL,
schedule_type VARCHAR(20) DEFAULT 'once',
scheduled_time TIMESTAMP NOT NULL,
notify_before_minutes INT DEFAULT 15,
auto_rollback BOOLEAN DEFAULT FALSE,
rollback_on_failure BOOLEAN DEFAULT TRUE,
status VARCHAR(20) DEFAULT 'scheduled',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100),
last_run_at TIMESTAMP,
last_run_result TEXT
)
"""
await database.execute(create_table)
simulation_id = f"sched-{str(uuid.uuid4())[:8]}"
insert_query = """
INSERT INTO scheduled_simulations (
id, name, description, cluster_id, analysis_id,
target_name, target_namespace, target_kind, change_type,
schedule_type, scheduled_time, notify_before_minutes,
auto_rollback, rollback_on_failure, status, created_by
) VALUES (
:id, :name, :description, :cluster_id, :analysis_id,
:target_name, :target_namespace, :target_kind, :change_type,
:schedule_type, :scheduled_time, :notify_before_minutes,
:auto_rollback, :rollback_on_failure, 'scheduled', :created_by
)
RETURNING *
"""
# Parse scheduled_time from ISO string to datetime (naive, without timezone)
from dateutil.parser import parse as parse_datetime
from datetime import timezone
try:
scheduled_time_dt = parse_datetime(request.scheduled_time)
# Convert timezone-aware datetime to UTC, then make naive for PostgreSQL TIMESTAMP column
if scheduled_time_dt.tzinfo is not None:
# Convert to UTC first to preserve the actual moment in time
scheduled_time_dt = scheduled_time_dt.astimezone(timezone.utc).replace(tzinfo=None)
except Exception as e:
logger.error("Invalid scheduled_time format", error=str(e), scheduled_time=request.scheduled_time)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid scheduled_time format: {request.scheduled_time}"
)
row = await database.fetch_one(insert_query, {
'id': simulation_id,
'name': request.name,
'description': request.description,
'cluster_id': request.cluster_id,
'analysis_id': request.analysis_id,
'target_name': request.target_name,
'target_namespace': request.target_namespace,
'target_kind': request.target_kind,
'change_type': request.change_type,
'schedule_type': request.schedule_type,
'scheduled_time': scheduled_time_dt,
'notify_before_minutes': request.notify_before_minutes,
'auto_rollback': request.auto_rollback,
'rollback_on_failure': request.rollback_on_failure,
'created_by': current_user.get('username', 'unknown')
})
logger.info(
"Scheduled simulation created",
simulation_id=simulation_id,
name=request.name,
user=current_user.get('username')
)
return ScheduledSimulationResponse(
id=row['id'],
name=row['name'],
description=row['description'],
cluster_id=row['cluster_id'],
analysis_id=row['analysis_id'],
target_name=row['target_name'],
target_namespace=row['target_namespace'],
target_kind=row['target_kind'],
change_type=row['change_type'],
schedule_type=row['schedule_type'],
scheduled_time=row['scheduled_time'].isoformat() if row['scheduled_time'] else '',
notify_before_minutes=row['notify_before_minutes'] or 15,
auto_rollback=row['auto_rollback'] or False,
rollback_on_failure=row['rollback_on_failure'] if row['rollback_on_failure'] is not None else True,
status=row['status'],
created_at=row['created_at'].isoformat() if row['created_at'] else '',
created_by=row['created_by']
)
except Exception as e:
logger.error("Failed to create scheduled simulation", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create scheduled simulation: {str(e)}"
)
@router.delete(
"/scheduled/{simulation_id}",
summary="Cancel Scheduled Simulation",
description="Cancel a scheduled simulation"
)
async def cancel_scheduled_simulation(
simulation_id: str,
current_user: dict = Depends(get_current_user)
):
"""Cancel a scheduled simulation"""
try:
from database.postgresql import database
# Check if simulation exists
query = "SELECT * FROM scheduled_simulations WHERE id = :id"
simulation = await database.fetch_one(query, {'id': simulation_id})
if not simulation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scheduled simulation not found"
)
# Delete the simulation
delete_query = "DELETE FROM scheduled_simulations WHERE id = :id"
await database.execute(delete_query, {'id': simulation_id})
logger.info(
"Scheduled simulation cancelled",
simulation_id=simulation_id,
user=current_user.get('username')
)
return {"message": "Scheduled simulation cancelled", "id": simulation_id}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to cancel scheduled simulation", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to cancel scheduled simulation: {str(e)}"
)
@router.post(
"/scheduled/{simulation_id}/run",
summary="Run Scheduled Simulation Now",
description="Execute a scheduled simulation immediately"
)
async def run_scheduled_simulation_now(
simulation_id: str,
current_user: dict = Depends(get_current_user),
service: NetworkPolicyService = Depends(get_service)
):
"""Run a scheduled simulation immediately"""
try:
from database.postgresql import database
# Get simulation details
query = "SELECT * FROM scheduled_simulations WHERE id = :id"
simulation = await database.fetch_one(query, {'id': simulation_id})
if not simulation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scheduled simulation not found"
)
# Run the impact simulation
# Construct target_id from stored fields (format: kind-namespace-name or just target_name)
target_id = simulation['target_name']
request = ImpactSimulationRequest(
cluster_id=simulation['cluster_id'],
analysis_id=simulation['analysis_id'],
target_id=target_id,
target_name=simulation['target_name'],
target_namespace=simulation['target_namespace'],
target_kind=simulation['target_kind'],
change_type=ChangeType(simulation['change_type'])
)
result = await run_impact_simulation(request, current_user, service)
# Update simulation status
update_query = """
UPDATE scheduled_simulations
SET last_run_at = NOW(),
last_run_result = :result,
status = CASE WHEN schedule_type = 'once' THEN 'completed' ELSE status END
WHERE id = :id
"""
await database.execute(update_query, {
'id': simulation_id,
'result': 'success' if result.success else 'failed'
})
logger.info(
"Scheduled simulation executed",
simulation_id=simulation_id,
user=current_user.get('username')
)
return {
"message": "Simulation executed",
"id": simulation_id,
"result": result
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to run scheduled simulation", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to run scheduled simulation: {str(e)}"
)
@router.get(
"/scheduled/worker/status",
summary="Get Scheduler Worker Status",
description="Get the status of the scheduled simulation worker"
)
async def get_scheduler_status(
current_user: dict = Depends(get_current_user)
):
"""Get the status of the scheduled simulation worker"""
try:
from workers.scheduled_simulation_worker import scheduled_simulation_worker
status_info = await scheduled_simulation_worker.get_status()
return {
"worker": "scheduled_simulation_worker",
**status_info
}
except Exception as e:
logger.error("Failed to get scheduler status", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get scheduler status: {str(e)}"
)
# =============================================================================
# Simulation History Endpoints
# =============================================================================
class SimulationHistoryResponse(BaseModel):
"""Response for a simulation history entry"""
id: str
simulation_id: str
cluster_id: str
analysis_id: OptionalType[str] = None
target_name: str
target_namespace: str
target_kind: str
change_type: str
total_affected: int
high_impact: int
medium_impact: int
low_impact: int
blast_radius: int
confidence_score: float
status: str
created_at: str
created_by: OptionalType[str] = None
duration_ms: OptionalType[int] = None
result_summary: OptionalType[dict] = None
class SimulationHistoryListResponse(BaseModel):
"""Response for listing simulation history"""
history: List[SimulationHistoryResponse]
total: int
async def save_simulation_to_history(
simulation_response: ImpactSimulationResponse,
request: ImpactSimulationRequest,
user: str,
duration_ms: int = 0
):
"""Save a simulation result to history"""
try:
from database.postgresql import database
# Ensure table exists
create_table = """
CREATE TABLE IF NOT EXISTS simulation_history (
id VARCHAR(50) PRIMARY KEY,
simulation_id VARCHAR(50) NOT NULL,
cluster_id VARCHAR(50) NOT NULL,
analysis_id VARCHAR(50),
target_name VARCHAR(255) NOT NULL,
target_namespace VARCHAR(255) NOT NULL,
target_kind VARCHAR(50) DEFAULT 'Deployment',
change_type VARCHAR(50) NOT NULL,
total_affected INT DEFAULT 0,
high_impact INT DEFAULT 0,
medium_impact INT DEFAULT 0,
low_impact INT DEFAULT 0,
blast_radius INT DEFAULT 0,
confidence_score DECIMAL(5,4) DEFAULT 0,
status VARCHAR(20) DEFAULT 'completed',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100),
duration_ms INT,
result_summary JSONB
)
"""
await database.execute(create_table)
# Create index if not exists
try:
await database.execute("""
CREATE INDEX IF NOT EXISTS idx_simulation_history_created
ON simulation_history(created_at DESC)
""")
await database.execute("""
CREATE INDEX IF NOT EXISTS idx_simulation_history_cluster
ON simulation_history(cluster_id)
""")
except:
pass # Index might already exist
history_id = f"hist-{str(uuid.uuid4())[:8]}"
# Prepare result summary
result_summary = {
"affected_services": [
{
"name": s.name,
"namespace": s.namespace,
"kind": s.kind,
"impact": s.impact.value if hasattr(s.impact, 'value') else s.impact,
"dependency": s.dependency.value if hasattr(s.dependency, 'value') else s.dependency,
"risk_score": s.risk_score
}
for s in simulation_response.affected_services[:20] # Limit to first 20
],
"timeline_projection": simulation_response.timeline_projection,
"rollback_scenario": simulation_response.rollback_scenario
}
insert_query = """
INSERT INTO simulation_history (
id, simulation_id, cluster_id, analysis_id,
target_name, target_namespace, target_kind, change_type,
total_affected, high_impact, medium_impact, low_impact,
blast_radius, confidence_score, status, created_by,
duration_ms, result_summary
) VALUES (
:id, :simulation_id, :cluster_id, :analysis_id,
:target_name, :target_namespace, :target_kind, :change_type,
:total_affected, :high_impact, :medium_impact, :low_impact,
:blast_radius, :confidence_score, :status, :created_by,
:duration_ms, :result_summary
)
"""
await database.execute(insert_query, {
'id': history_id,
'simulation_id': simulation_response.simulation_id,
'cluster_id': str(request.cluster_id),
'analysis_id': str(request.analysis_id) if request.analysis_id else None,
'target_name': request.target_name,
'target_namespace': request.target_namespace,
'target_kind': request.target_kind,
'change_type': request.change_type.value if hasattr(request.change_type, 'value') else request.change_type,
'total_affected': simulation_response.summary.total_affected,
'high_impact': simulation_response.summary.high_impact,
'medium_impact': simulation_response.summary.medium_impact,
'low_impact': simulation_response.summary.low_impact,
'blast_radius': simulation_response.summary.blast_radius,
'confidence_score': simulation_response.summary.confidence_score,
'status': 'completed' if simulation_response.success else 'failed',
'created_by': user,
'duration_ms': duration_ms,
'result_summary': json.dumps(result_summary, default=str)
})
logger.info(
"Simulation saved to history",
history_id=history_id,
simulation_id=simulation_response.simulation_id
)
except Exception as e:
logger.warning("Failed to save simulation to history", error=str(e))
# Don't raise - history save failure shouldn't fail the simulation
@router.get(
"/history",
response_model=SimulationHistoryListResponse,
summary="List Simulation History",
description="Get simulation history for the current user"
)
async def list_simulation_history(
cluster_id: OptionalType[str] = Query(None, description="Filter by cluster"),
analysis_id: OptionalType[str] = Query(None, description="Filter by analysis"),
limit: int = Query(50, description="Maximum number of results"),
offset: int = Query(0, description="Offset for pagination"),
current_user: dict = Depends(get_current_user)
):
"""List simulation history"""
try:
from database.postgresql import database
# Check if table exists
check_query = """
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'simulation_history'
) as exists
"""
exists = await database.fetch_one(check_query)
if not exists or not exists.get('exists'):
# Create table if it doesn't exist
create_table = """
CREATE TABLE IF NOT EXISTS simulation_history (
id VARCHAR(50) PRIMARY KEY,
simulation_id VARCHAR(50) NOT NULL,
cluster_id VARCHAR(50) NOT NULL,
analysis_id VARCHAR(50),
target_name VARCHAR(255) NOT NULL,
target_namespace VARCHAR(255) NOT NULL,
target_kind VARCHAR(50) DEFAULT 'Deployment',
change_type VARCHAR(50) NOT NULL,
total_affected INT DEFAULT 0,
high_impact INT DEFAULT 0,
medium_impact INT DEFAULT 0,
low_impact INT DEFAULT 0,
blast_radius INT DEFAULT 0,
confidence_score DECIMAL(5,4) DEFAULT 0,
status VARCHAR(20) DEFAULT 'completed',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100),
duration_ms INT,
result_summary JSONB
)
"""
await database.execute(create_table)
return SimulationHistoryListResponse(history=[], total=0)
# Build query
query = """
SELECT * FROM simulation_history
WHERE 1=1
"""
count_query = """
SELECT COUNT(*) as total FROM simulation_history
WHERE 1=1
"""
params = {}
if cluster_id:
query += " AND cluster_id = :cluster_id"
count_query += " AND cluster_id = :cluster_id"
params['cluster_id'] = cluster_id
if analysis_id:
query += " AND analysis_id = :analysis_id"
count_query += " AND analysis_id = :analysis_id"
params['analysis_id'] = analysis_id
query += " ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
params['limit'] = limit
params['offset'] = offset
rows = await database.fetch_all(query, params)
total_row = await database.fetch_one(count_query, {k: v for k, v in params.items() if k not in ['limit', 'offset']})
total = total_row.get('total', 0) if total_row else 0
history = []
for row in rows:
result_summary = None
if row['result_summary']:
try:
result_summary = json.loads(row['result_summary']) if isinstance(row['result_summary'], str) else row['result_summary']
except:
result_summary = None
history.append(SimulationHistoryResponse(
id=row['id'],
simulation_id=row['simulation_id'],
cluster_id=row['cluster_id'],
analysis_id=row['analysis_id'],
target_name=row['target_name'],
target_namespace=row['target_namespace'],
target_kind=row['target_kind'],
change_type=row['change_type'],
total_affected=row['total_affected'] or 0,
high_impact=row['high_impact'] or 0,
medium_impact=row['medium_impact'] or 0,
low_impact=row['low_impact'] or 0,
blast_radius=row['blast_radius'] or 0,
confidence_score=float(row['confidence_score']) if row['confidence_score'] else 0.0,
status=row['status'],
created_at=row['created_at'].isoformat() if row['created_at'] else '',
created_by=row['created_by'],
duration_ms=row['duration_ms'],
result_summary=result_summary
))
return SimulationHistoryListResponse(history=history, total=total)
except Exception as e:
logger.error("Failed to list simulation history", error=str(e))
return SimulationHistoryListResponse(history=[], total=0)
@router.get(
"/history/{history_id}",
response_model=SimulationHistoryResponse,
summary="Get Simulation History Entry",
description="Get a specific simulation history entry"
)
async def get_simulation_history_entry(
history_id: str,
current_user: dict = Depends(get_current_user)
):
"""Get a specific simulation history entry"""
try:
from database.postgresql import database
query = "SELECT * FROM simulation_history WHERE id = :id"
row = await database.fetch_one(query, {'id': history_id})
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Simulation history entry not found"
)
result_summary = None
if row['result_summary']:
try:
result_summary = json.loads(row['result_summary']) if isinstance(row['result_summary'], str) else row['result_summary']
except:
result_summary = None
return SimulationHistoryResponse(
id=row['id'],
simulation_id=row['simulation_id'],
cluster_id=row['cluster_id'],
analysis_id=row['analysis_id'],
target_name=row['target_name'],
target_namespace=row['target_namespace'],
target_kind=row['target_kind'],
change_type=row['change_type'],
total_affected=row['total_affected'] or 0,
high_impact=row['high_impact'] or 0,
medium_impact=row['medium_impact'] or 0,
low_impact=row['low_impact'] or 0,
blast_radius=row['blast_radius'] or 0,
confidence_score=float(row['confidence_score']) if row['confidence_score'] else 0.0,
status=row['status'],
created_at=row['created_at'].isoformat() if row['created_at'] else '',
created_by=row['created_by'],
duration_ms=row['duration_ms'],
result_summary=result_summary
)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get simulation history entry", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get simulation history entry: {str(e)}"
)
@router.delete(
"/history/{history_id}",
summary="Delete Simulation History Entry",
description="Delete a simulation history entry"
)
async def delete_simulation_history_entry(
history_id: str,
current_user: dict = Depends(get_current_user)
):
"""Delete a simulation history entry"""
try:
from database.postgresql import database
# Check if entry exists
query = "SELECT * FROM simulation_history WHERE id = :id"
entry = await database.fetch_one(query, {'id': history_id})
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Simulation history entry not found"
)
# Delete the entry
delete_query = "DELETE FROM simulation_history WHERE id = :id"
await database.execute(delete_query, {'id': history_id})
logger.info(
"Simulation history entry deleted",
history_id=history_id,
user=current_user.get('username')
)
return {"message": "History entry deleted", "id": history_id}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to delete simulation history entry", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete simulation history entry: {str(e)}"
)