mirror of
https://github.com/taylanbakircioglu/flowfish.git
synced 2026-09-20 09:33:27 +00:00
6e503368f7
- Grafana Beyla DaemonSet for kernel-level HTTP/gRPC/DNS capture (passive, zero application changes, W3C traceparent header propagation) - flowfish-l7-collector in-cluster bridge: OTLP receiver + buffered pull API - L7 Ingestion Service: K8s service-proxy poll → enrich → RabbitMQ - ClickHouse l7_http_flows / l7_grpc_flows / l7_dns_flows + APM RED MVs - Neo4j L7Workload nodes + SAME_WORKLOAD cross-cluster bridges - New pages: Service Map, Trace Explorer, APM Services List, APM Service Detail - Analysis Wizard now supports L4 / L7 / Both modes with HTTP/gRPC/DNS picks - Integration Hub gains L7 dependency summary + tree-summary integrations - Multi-Cluster Management: dual-agent install (Inspector Gadget L4 + Beyla L7), runtime OpenShift detection so SCCs auto-install with kubectl too - ServiceMap edge → Trace Explorer drill-down with virtual_trace_id correlation - Docs: new L7 architecture diagram, README L7 sections, 3 new screenshots
772 lines
32 KiB
Python
772 lines
32 KiB
Python
"""
|
|
Cluster Manager gRPC Server
|
|
|
|
Gateway Pattern Architecture:
|
|
- This service acts as the SINGLE entry point for ALL Kubernetes API access
|
|
- Supports both in-cluster (default) and remote cluster connections
|
|
- Remote cluster credentials are fetched from PostgreSQL and decrypted
|
|
- Client instances are cached with TTL for performance
|
|
- Temp files (CA certs) written to /tmp emptyDir volume
|
|
|
|
Multi-Cluster Support:
|
|
- cluster_id="default" or "0" or empty -> in-cluster client
|
|
- cluster_id=<numeric_id> -> remote cluster (fetch credentials from DB)
|
|
"""
|
|
|
|
import grpc
|
|
import json
|
|
import logging
|
|
from concurrent import futures
|
|
import structlog
|
|
import asyncio
|
|
import os
|
|
import base64
|
|
from typing import Optional, Dict, Any
|
|
|
|
from app.config import settings
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging configuration
|
|
#
|
|
# The Dockerfile entrypoint runs `python -m app.grpc_server`, so the parent
|
|
# `main.py` (which had `logging.basicConfig(...)`) is never imported. Without
|
|
# any explicit setup, structlog falls back to its development defaults and
|
|
# emits *every* log level — which is why operators were seeing a flood of
|
|
# `[debug ] Cluster configured as in-cluster` lines on every cluster scan.
|
|
#
|
|
# We respect the LOG_LEVEL env var (defaults to INFO) and apply it to both
|
|
# the stdlib root logger AND structlog's filtering bound logger so that
|
|
# `logger.debug(...)` calls are dropped at the structlog layer before they
|
|
# even reach the formatter.
|
|
# ---------------------------------------------------------------------------
|
|
_log_level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
_log_level = getattr(logging, _log_level_name, logging.INFO)
|
|
logging.basicConfig(
|
|
level=_log_level,
|
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
)
|
|
structlog.configure(
|
|
wrapper_class=structlog.make_filtering_bound_logger(_log_level),
|
|
processors=[
|
|
structlog.contextvars.merge_contextvars,
|
|
structlog.processors.add_log_level,
|
|
structlog.processors.TimeStamper(fmt="iso"),
|
|
structlog.dev.ConsoleRenderer(),
|
|
],
|
|
cache_logger_on_first_use=True,
|
|
)
|
|
from app.kubernetes_client import kubernetes_client, KubernetesClient, KubernetesClientFactory
|
|
from app.database import db_manager
|
|
|
|
# Import generated proto files (will be generated during build)
|
|
try:
|
|
from proto import cluster_manager_pb2
|
|
from proto import cluster_manager_pb2_grpc
|
|
except ImportError:
|
|
# Fallback for development
|
|
import sys
|
|
sys.path.insert(0, '/app')
|
|
from proto import cluster_manager_pb2
|
|
from proto import cluster_manager_pb2_grpc
|
|
|
|
# Encryption support - same as backend
|
|
try:
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
_ENCRYPTION_KEY = os.environ.get("FLOWFISH_ENCRYPTION_KEY")
|
|
_cipher = Fernet(_ENCRYPTION_KEY.encode()) if _ENCRYPTION_KEY else None
|
|
except Exception:
|
|
_cipher = None
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
def _decrypt_value(ciphertext: str) -> str:
|
|
"""
|
|
Decrypt a Fernet-encrypted string.
|
|
Falls back to returning the original value if decryption fails.
|
|
"""
|
|
if not ciphertext:
|
|
return ""
|
|
|
|
# Check if value looks like it could be Fernet encrypted
|
|
if not ciphertext.startswith('gAAAAA'):
|
|
return ciphertext
|
|
|
|
if not _cipher:
|
|
logger.warning("Encryption not configured, returning value as-is")
|
|
return ciphertext
|
|
|
|
try:
|
|
decrypted = _cipher.decrypt(ciphertext.encode('utf-8'))
|
|
return decrypted.decode('utf-8')
|
|
except (InvalidToken, Exception) as e:
|
|
logger.warning("Decryption failed, returning value as-is", error=str(e))
|
|
return ciphertext
|
|
|
|
|
|
class ClusterManagerServicer(cluster_manager_pb2_grpc.ClusterManagerServiceServicer):
|
|
"""
|
|
gRPC Servicer for Cluster Manager
|
|
|
|
Gateway Pattern:
|
|
- Default: Uses in-cluster ServiceAccount with ClusterRole
|
|
- Remote: Fetches credentials from PostgreSQL and creates appropriate client
|
|
|
|
Multi-Cluster Support:
|
|
- cluster_id="default" or "0" or empty -> in-cluster client
|
|
- cluster_id=<numeric_id> -> remote cluster (credentials from DB)
|
|
"""
|
|
|
|
def __init__(self):
|
|
# Default in-cluster client (backward compatible)
|
|
self.k8s_client = kubernetes_client
|
|
self._db = db_manager
|
|
logger.info("ClusterManagerServicer initialized with gateway pattern support")
|
|
|
|
async def _get_k8s_client(self, cluster_id: str) -> KubernetesClient:
|
|
"""
|
|
Get appropriate Kubernetes client based on cluster_id.
|
|
|
|
Gateway Pattern Implementation:
|
|
- Default/empty/"0" cluster_id -> in-cluster client
|
|
- Numeric cluster_id -> fetch credentials from DB and create remote client
|
|
|
|
For remote clusters, errors are raised instead of silently falling back
|
|
to the in-cluster client, so callers can report meaningful errors.
|
|
|
|
Args:
|
|
cluster_id: Cluster identifier from gRPC request
|
|
|
|
Returns:
|
|
KubernetesClient instance (from cache or newly created)
|
|
|
|
Raises:
|
|
ValueError: If cluster_id is invalid, cluster not found, or credentials missing
|
|
Exception: If client creation fails for any reason
|
|
"""
|
|
if not cluster_id or cluster_id == "default" or cluster_id == "0":
|
|
logger.debug("Using in-cluster client", cluster_id=cluster_id)
|
|
return self.k8s_client
|
|
|
|
try:
|
|
cluster_id_int = int(cluster_id)
|
|
except ValueError:
|
|
raise ValueError(f"Invalid cluster_id format: {cluster_id}")
|
|
|
|
cluster = await self._db.get_cluster_credentials(cluster_id_int)
|
|
|
|
if not cluster:
|
|
raise ValueError(f"Cluster {cluster_id} not found in database or not active")
|
|
|
|
connection_type = cluster.get("connection_type", "in-cluster")
|
|
|
|
if connection_type == "in-cluster":
|
|
logger.debug("Cluster configured as in-cluster", cluster_id=cluster_id)
|
|
return self.k8s_client
|
|
|
|
token = _decrypt_value(cluster.get("token_encrypted") or "")
|
|
ca_cert = _decrypt_value(cluster.get("ca_cert_encrypted") or "")
|
|
kubeconfig = _decrypt_value(cluster.get("kubeconfig_encrypted") or "")
|
|
|
|
api_server_url = cluster.get("api_server_url")
|
|
skip_tls_verify = cluster.get("skip_tls_verify") or False
|
|
|
|
logger.info("Creating remote cluster client",
|
|
cluster_id=cluster_id,
|
|
cluster_name=cluster.get("name"),
|
|
connection_type=connection_type,
|
|
api_server_url=api_server_url,
|
|
has_token=bool(token),
|
|
has_ca_cert=bool(ca_cert),
|
|
skip_tls_verify=skip_tls_verify)
|
|
|
|
try:
|
|
if connection_type == "token":
|
|
if not api_server_url or not token:
|
|
raise ValueError(
|
|
f"Cluster {cluster_id}: missing api_server_url or token for token connection"
|
|
)
|
|
|
|
return KubernetesClientFactory.get_client(
|
|
cluster_id=cluster_id,
|
|
connection_type="token",
|
|
api_server_url=api_server_url,
|
|
token=token,
|
|
ca_cert=base64.b64encode(ca_cert.encode()).decode() if ca_cert else None,
|
|
skip_tls_verify=skip_tls_verify
|
|
)
|
|
|
|
elif connection_type == "kubeconfig":
|
|
if not kubeconfig:
|
|
raise ValueError(
|
|
f"Cluster {cluster_id}: missing kubeconfig content for kubeconfig connection"
|
|
)
|
|
|
|
return KubernetesClientFactory.get_client(
|
|
cluster_id=cluster_id,
|
|
connection_type="kubeconfig",
|
|
kubeconfig_content=base64.b64encode(kubeconfig.encode()).decode()
|
|
)
|
|
|
|
else:
|
|
raise ValueError(
|
|
f"Cluster {cluster_id}: unsupported connection type '{connection_type}'"
|
|
)
|
|
except Exception as e:
|
|
KubernetesClientFactory.invalidate(cluster_id)
|
|
raise
|
|
|
|
def _invalidate_on_error(self, cluster_id: str):
|
|
"""Invalidate cached K8s client for remote clusters on API errors."""
|
|
if cluster_id and cluster_id not in ("default", "0"):
|
|
KubernetesClientFactory.invalidate(cluster_id)
|
|
|
|
async def GetClusterInfo(self, request, context):
|
|
"""Get basic cluster information"""
|
|
cluster_id = request.cluster_id or "default"
|
|
logger.info("GetClusterInfo called", cluster_id=cluster_id)
|
|
|
|
try:
|
|
# Get appropriate client for the cluster
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
info = await k8s_client.get_cluster_info()
|
|
|
|
return cluster_manager_pb2.ClusterInfoResponse(
|
|
k8s_version=info.get("k8s_version") or "",
|
|
total_nodes=info.get("total_nodes", 0),
|
|
total_pods=info.get("total_pods", 0),
|
|
total_namespaces=info.get("total_namespaces", 0),
|
|
platform=info.get("platform") or "",
|
|
error=info.get("error") or ""
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("GetClusterInfo failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ClusterInfoResponse(error=str(e))
|
|
|
|
async def ListNamespaces(self, request, context):
|
|
"""List all namespaces"""
|
|
cluster_id = request.cluster_id or "default"
|
|
logger.info("ListNamespaces called", cluster_id=cluster_id)
|
|
|
|
try:
|
|
# Get appropriate client for the cluster
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
namespaces = await k8s_client.list_namespaces()
|
|
|
|
ns_infos = [
|
|
cluster_manager_pb2.NamespaceInfo(
|
|
name=ns["name"],
|
|
uid=ns.get("uid") or "",
|
|
status=ns.get("status") or "",
|
|
labels=ns.get("labels") or {},
|
|
created_at=ns.get("created_at") or ""
|
|
)
|
|
for ns in namespaces
|
|
]
|
|
|
|
return cluster_manager_pb2.ListNamespacesResponse(
|
|
namespaces=ns_infos,
|
|
count=len(ns_infos),
|
|
error=""
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListNamespaces failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListNamespacesResponse(error=str(e))
|
|
|
|
async def ListDeployments(self, request, context):
|
|
"""List deployments"""
|
|
cluster_id = request.cluster_id or "default"
|
|
namespace = request.namespace if request.namespace else None
|
|
logger.info("ListDeployments called", cluster_id=cluster_id, namespace=namespace)
|
|
|
|
try:
|
|
# Get appropriate client for the cluster
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
deployments = await k8s_client.list_deployments(namespace)
|
|
|
|
dep_infos = [
|
|
cluster_manager_pb2.DeploymentInfo(
|
|
name=dep["name"],
|
|
namespace=dep["namespace"],
|
|
uid=dep.get("uid") or "",
|
|
replicas=dep.get("replicas") or 0,
|
|
available_replicas=dep.get("available_replicas") or 0,
|
|
labels=dep.get("labels") or {},
|
|
annotations=self._filter_annotations(dep.get("annotations")),
|
|
image=dep.get("image") or "",
|
|
created_at=dep.get("created_at") or "",
|
|
spec_hash=dep.get("spec_hash") or "",
|
|
containers_json=json.dumps(dep.get("containers") or [], default=str)
|
|
)
|
|
for dep in deployments
|
|
]
|
|
|
|
return cluster_manager_pb2.ListDeploymentsResponse(
|
|
deployments=dep_infos,
|
|
count=len(dep_infos),
|
|
error=""
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListDeployments failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListDeploymentsResponse(error=str(e))
|
|
|
|
_NOISE_ANNOTATION_PREFIXES = (
|
|
'kubectl.kubernetes.io/',
|
|
'kubernetes.io/',
|
|
'openshift.io/',
|
|
'openshift.openshift.io/',
|
|
'k8s.v1.cni.cncf.io/',
|
|
'k8s.ovn.org/',
|
|
'seccomp.security.alpha.kubernetes.io/',
|
|
)
|
|
|
|
@classmethod
|
|
def _filter_annotations(cls, raw_annotations: dict) -> dict:
|
|
"""Filter out internal/large annotations, keep user-defined ones"""
|
|
if not raw_annotations:
|
|
return {}
|
|
return {
|
|
k: v for k, v in raw_annotations.items()
|
|
if not any(k.startswith(p) for p in cls._NOISE_ANNOTATION_PREFIXES)
|
|
and len(str(v)) < 500
|
|
}
|
|
|
|
async def ListPods(self, request, context):
|
|
"""List pods"""
|
|
cluster_id = request.cluster_id or "default"
|
|
namespace = request.namespace if request.namespace else None
|
|
label_selector = request.label_selector if request.label_selector else None
|
|
logger.info("ListPods called", cluster_id=cluster_id, namespace=namespace, label_selector=label_selector)
|
|
|
|
try:
|
|
# Get appropriate client for the cluster
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
pods = await k8s_client.list_pods(namespace, label_selector)
|
|
|
|
pod_infos = [
|
|
cluster_manager_pb2.PodInfo(
|
|
name=pod["name"],
|
|
namespace=pod["namespace"],
|
|
uid=pod.get("uid") or "",
|
|
status=pod.get("status") or "",
|
|
node_name=pod.get("node_name") or "",
|
|
labels=pod.get("labels") or {},
|
|
annotations=self._filter_annotations(pod.get("annotations")),
|
|
ip=pod.get("ip") or "",
|
|
image=pod.get("image") or "",
|
|
created_at=pod.get("created_at") or ""
|
|
)
|
|
for pod in pods
|
|
]
|
|
|
|
return cluster_manager_pb2.ListPodsResponse(
|
|
pods=pod_infos,
|
|
count=len(pod_infos),
|
|
error=""
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListPods failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListPodsResponse(error=str(e))
|
|
|
|
async def ListServices(self, request, context):
|
|
"""List services"""
|
|
cluster_id = request.cluster_id or "default"
|
|
namespace = request.namespace if request.namespace else None
|
|
logger.info("ListServices called", cluster_id=cluster_id, namespace=namespace)
|
|
|
|
try:
|
|
# Get appropriate client for the cluster
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
services = await k8s_client.list_services(namespace)
|
|
|
|
svc_infos = [
|
|
cluster_manager_pb2.ServiceInfo(
|
|
name=svc["name"],
|
|
namespace=svc["namespace"],
|
|
uid=svc.get("uid") or "",
|
|
type=svc.get("type") or "",
|
|
cluster_ip=svc.get("cluster_ip") or "",
|
|
ports=[
|
|
cluster_manager_pb2.ServicePort(
|
|
port=p["port"],
|
|
protocol=p["protocol"],
|
|
target_port=p["target_port"],
|
|
name=p.get("name") or "",
|
|
app_protocol=p.get("app_protocol") or ""
|
|
)
|
|
for p in svc.get("ports", [])
|
|
],
|
|
labels=svc.get("labels") or {},
|
|
selector=svc.get("selector") or {},
|
|
created_at=svc.get("created_at") or ""
|
|
)
|
|
for svc in services
|
|
]
|
|
|
|
return cluster_manager_pb2.ListServicesResponse(
|
|
services=svc_infos,
|
|
count=len(svc_infos),
|
|
error=""
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListServices failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListServicesResponse(error=str(e))
|
|
|
|
async def ListStatefulSets(self, request, context):
|
|
"""List statefulsets"""
|
|
cluster_id = request.cluster_id or "default"
|
|
namespace = request.namespace if request.namespace else None
|
|
logger.info("ListStatefulSets called", cluster_id=cluster_id, namespace=namespace)
|
|
|
|
try:
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
statefulsets = await k8s_client.list_statefulsets(namespace)
|
|
|
|
sts_infos = [
|
|
cluster_manager_pb2.StatefulSetInfo(
|
|
name=sts["name"],
|
|
namespace=sts["namespace"],
|
|
uid=sts.get("uid") or "",
|
|
replicas=sts.get("replicas") or 0,
|
|
ready_replicas=sts.get("ready_replicas") or 0,
|
|
labels=sts.get("labels") or {},
|
|
annotations=self._filter_annotations(sts.get("annotations")),
|
|
image=sts.get("image") or "",
|
|
created_at=sts.get("created_at") or "",
|
|
spec_hash=sts.get("spec_hash") or "",
|
|
containers_json=json.dumps(sts.get("containers") or [], default=str)
|
|
)
|
|
for sts in statefulsets
|
|
]
|
|
|
|
return cluster_manager_pb2.ListStatefulSetsResponse(
|
|
statefulsets=sts_infos,
|
|
count=len(sts_infos),
|
|
error=""
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListStatefulSets failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListStatefulSetsResponse(error=str(e))
|
|
|
|
async def ListConfigMaps(self, request, context):
|
|
"""List configmaps with data hash"""
|
|
cluster_id = request.cluster_id or "default"
|
|
namespace = request.namespace if request.namespace else None
|
|
logger.info("ListConfigMaps called", cluster_id=cluster_id, namespace=namespace)
|
|
|
|
try:
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
configmaps = await k8s_client.list_configmaps(namespace)
|
|
|
|
cm_infos = [
|
|
cluster_manager_pb2.ConfigMapInfo(
|
|
name=cm["name"],
|
|
namespace=cm["namespace"],
|
|
uid=cm.get("uid") or "",
|
|
data_hash=cm.get("data_hash") or "empty",
|
|
created_at=cm.get("created_at") or ""
|
|
)
|
|
for cm in configmaps
|
|
]
|
|
|
|
return cluster_manager_pb2.ListConfigMapsResponse(
|
|
configmaps=cm_infos,
|
|
count=len(cm_infos),
|
|
error=""
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListConfigMaps failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListConfigMapsResponse(error=str(e))
|
|
|
|
async def ListSecrets(self, request, context):
|
|
"""List secrets with data hash"""
|
|
cluster_id = request.cluster_id or "default"
|
|
namespace = request.namespace if request.namespace else None
|
|
logger.info("ListSecrets called", cluster_id=cluster_id, namespace=namespace)
|
|
|
|
try:
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
secrets = await k8s_client.list_secrets(namespace)
|
|
|
|
sec_infos = [
|
|
cluster_manager_pb2.SecretInfo(
|
|
name=sec["name"],
|
|
namespace=sec["namespace"],
|
|
uid=sec.get("uid") or "",
|
|
data_hash=sec.get("data_hash") or "empty",
|
|
type=sec.get("type") or "Opaque",
|
|
created_at=sec.get("created_at") or ""
|
|
)
|
|
for sec in secrets
|
|
]
|
|
|
|
return cluster_manager_pb2.ListSecretsResponse(
|
|
secrets=sec_infos,
|
|
count=len(sec_infos),
|
|
error=""
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListSecrets failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListSecretsResponse(error=str(e))
|
|
|
|
async def ListNetworkPolicies(self, request, context):
|
|
"""List network policies with spec hash"""
|
|
cluster_id = request.cluster_id or "default"
|
|
namespace = request.namespace if request.namespace else None
|
|
|
|
try:
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
netpols = await k8s_client.list_network_policies(namespace)
|
|
infos = [
|
|
cluster_manager_pb2.NetworkPolicyInfo(
|
|
name=np["name"], namespace=np["namespace"],
|
|
uid=np.get("uid") or "", spec_hash=np.get("spec_hash") or "",
|
|
created_at=np.get("created_at") or ""
|
|
) for np in netpols
|
|
]
|
|
return cluster_manager_pb2.ListNetworkPoliciesResponse(network_policies=infos, count=len(infos), error="")
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListNetworkPolicies failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListNetworkPoliciesResponse(error=str(e))
|
|
|
|
async def ListIngresses(self, request, context):
|
|
"""List ingresses with spec hash"""
|
|
cluster_id = request.cluster_id or "default"
|
|
namespace = request.namespace if request.namespace else None
|
|
|
|
try:
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
ingresses = await k8s_client.list_ingresses(namespace)
|
|
infos = [
|
|
cluster_manager_pb2.IngressInfo(
|
|
name=ing["name"], namespace=ing["namespace"],
|
|
uid=ing.get("uid") or "", spec_hash=ing.get("spec_hash") or "",
|
|
hosts=ing.get("hosts") or [],
|
|
created_at=ing.get("created_at") or ""
|
|
) for ing in ingresses
|
|
]
|
|
return cluster_manager_pb2.ListIngressesResponse(ingresses=infos, count=len(infos), error="")
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListIngresses failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListIngressesResponse(error=str(e))
|
|
|
|
async def ListRoutes(self, request, context):
|
|
"""List OpenShift routes with spec hash"""
|
|
cluster_id = request.cluster_id or "default"
|
|
namespace = request.namespace if request.namespace else None
|
|
|
|
try:
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
routes = await k8s_client.list_routes(namespace)
|
|
infos = [
|
|
cluster_manager_pb2.RouteInfo(
|
|
name=rt["name"], namespace=rt["namespace"],
|
|
uid=rt.get("uid") or "", spec_hash=rt.get("spec_hash") or "",
|
|
host=rt.get("host") or "",
|
|
created_at=rt.get("created_at") or ""
|
|
) for rt in routes
|
|
]
|
|
return cluster_manager_pb2.ListRoutesResponse(routes=infos, count=len(infos), error="")
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListRoutes failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListRoutesResponse(error=str(e))
|
|
|
|
async def ListNodes(self, request, context):
|
|
"""List cluster nodes with their IPs"""
|
|
cluster_id = request.cluster_id or "default"
|
|
logger.info("ListNodes called", cluster_id=cluster_id)
|
|
|
|
try:
|
|
# Get appropriate client for the cluster
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
nodes = await k8s_client.list_nodes()
|
|
|
|
node_infos = [
|
|
cluster_manager_pb2.NodeInfo(
|
|
name=node["name"],
|
|
uid=node.get("uid") or "",
|
|
internal_ip=node.get("internal_ip") or "",
|
|
external_ip=node.get("external_ip") or "",
|
|
status=node.get("status") or "",
|
|
labels=node.get("labels") or {},
|
|
kubelet_version=node.get("kubelet_version") or "",
|
|
os_image=node.get("os_image") or "",
|
|
container_runtime=node.get("container_runtime") or "",
|
|
architecture=node.get("architecture") or "",
|
|
created_at=node.get("created_at") or ""
|
|
)
|
|
for node in nodes
|
|
]
|
|
|
|
return cluster_manager_pb2.ListNodesResponse(
|
|
nodes=node_infos,
|
|
count=len(node_infos),
|
|
error=""
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("ListNodes failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.ListNodesResponse(error=str(e))
|
|
|
|
async def GetLabels(self, request, context):
|
|
"""Get unique labels from resources"""
|
|
cluster_id = request.cluster_id or "default"
|
|
resource_type = request.resource_type or "pods"
|
|
namespace = request.namespace if request.namespace else None
|
|
logger.info("GetLabels called", cluster_id=cluster_id, resource_type=resource_type, namespace=namespace)
|
|
|
|
try:
|
|
# Get appropriate client for the cluster
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
labels = await k8s_client.get_labels(resource_type, namespace)
|
|
|
|
return cluster_manager_pb2.GetLabelsResponse(
|
|
labels=labels,
|
|
count=len(labels),
|
|
error=""
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("GetLabels failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.GetLabelsResponse(error=str(e))
|
|
|
|
async def HealthCheck(self, request, context):
|
|
"""Health check - uses default in-cluster client"""
|
|
# Note: HealthCheckRequest proto doesn't have cluster_id field
|
|
# This endpoint is for checking cluster-manager service health, not remote clusters
|
|
try:
|
|
# Use default in-cluster client for health check
|
|
info = await self.k8s_client.get_cluster_info()
|
|
healthy = info.get("error") is None
|
|
|
|
return cluster_manager_pb2.HealthCheckResponse(
|
|
healthy=healthy,
|
|
message="OK" if healthy else info.get("error", "Unknown error"),
|
|
kubernetes_status="connected" if healthy else "disconnected"
|
|
)
|
|
except Exception as e:
|
|
logger.error("HealthCheck failed", error=str(e))
|
|
return cluster_manager_pb2.HealthCheckResponse(
|
|
healthy=False,
|
|
message=str(e),
|
|
kubernetes_status="disconnected"
|
|
)
|
|
|
|
async def CheckGadgetHealth(self, request, context):
|
|
"""Check Inspector Gadget DaemonSet health"""
|
|
cluster_id = request.cluster_id or "default"
|
|
gadget_namespace = request.gadget_namespace
|
|
|
|
if not gadget_namespace:
|
|
logger.error("gadget_namespace is required but not provided")
|
|
return cluster_manager_pb2.GadgetHealthResponse(
|
|
health_status="not_installed",
|
|
error="gadget_namespace not configured",
|
|
pods_ready=0,
|
|
pods_total=0
|
|
)
|
|
logger.info("CheckGadgetHealth called", cluster_id=cluster_id, gadget_namespace=gadget_namespace)
|
|
|
|
try:
|
|
# Get appropriate client for the cluster
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
health = await k8s_client.check_gadget_health(gadget_namespace)
|
|
|
|
return cluster_manager_pb2.GadgetHealthResponse(
|
|
health_status=health.get("health_status", "unknown"),
|
|
version=health.get("version") or "",
|
|
error=health.get("error") or "",
|
|
pods_ready=health.get("pods_ready", 0),
|
|
pods_total=health.get("pods_total", 0),
|
|
ebpf_capable=health.get("details", {}).get("ebpf_capable", False),
|
|
total_restarts=health.get("details", {}).get("total_restarts", 0),
|
|
issues=health.get("details", {}).get("issues", [])
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("CheckGadgetHealth failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.GadgetHealthResponse(
|
|
health_status="unknown",
|
|
error=str(e),
|
|
pods_ready=0,
|
|
pods_total=0
|
|
)
|
|
|
|
async def CheckBeylaHealth(self, request, context):
|
|
"""Check Grafana Beyla DaemonSet + L7 Collector health"""
|
|
cluster_id = request.cluster_id or "default"
|
|
beyla_namespace = request.beyla_namespace
|
|
|
|
if not beyla_namespace:
|
|
logger.error("beyla_namespace is required but not provided")
|
|
return cluster_manager_pb2.BeylaHealthResponse(
|
|
health_status="not_installed",
|
|
error="beyla_namespace is required",
|
|
)
|
|
logger.info("CheckBeylaHealth called", cluster_id=cluster_id, beyla_namespace=beyla_namespace)
|
|
|
|
try:
|
|
k8s_client = await self._get_k8s_client(cluster_id)
|
|
health = await k8s_client.check_beyla_health(beyla_namespace)
|
|
|
|
return cluster_manager_pb2.BeylaHealthResponse(
|
|
health_status=health.get("health_status", "unknown"),
|
|
version=health.get("version") or "",
|
|
error=health.get("error") or "",
|
|
daemonset_ready=health.get("daemonset_ready", 0),
|
|
daemonset_total=health.get("daemonset_total", 0),
|
|
collector_ready=health.get("collector_ready", False),
|
|
issues=health.get("issues", []),
|
|
)
|
|
except Exception as e:
|
|
self._invalidate_on_error(cluster_id)
|
|
logger.error("CheckBeylaHealth failed", cluster_id=cluster_id, error=str(e))
|
|
return cluster_manager_pb2.BeylaHealthResponse(
|
|
health_status="unknown",
|
|
error=str(e),
|
|
)
|
|
|
|
|
|
async def serve():
|
|
"""Start gRPC server"""
|
|
server = grpc.aio.server(
|
|
futures.ThreadPoolExecutor(max_workers=10),
|
|
options=[
|
|
('grpc.max_send_message_length', 50 * 1024 * 1024),
|
|
('grpc.max_receive_message_length', 50 * 1024 * 1024),
|
|
]
|
|
)
|
|
|
|
cluster_manager_pb2_grpc.add_ClusterManagerServiceServicer_to_server(
|
|
ClusterManagerServicer(), server
|
|
)
|
|
|
|
listen_addr = f"0.0.0.0:{settings.GRPC_PORT}"
|
|
server.add_insecure_port(listen_addr)
|
|
|
|
logger.info("Starting Cluster Manager gRPC server", address=listen_addr)
|
|
|
|
await server.start()
|
|
|
|
try:
|
|
await server.wait_for_termination()
|
|
except KeyboardInterrupt:
|
|
logger.info("Shutting down Cluster Manager server")
|
|
await server.stop(5)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(serve())
|