use crate::kube::portforward::PortForwardSessionConfig; use crate::kube::ClusterClient; use crate::shell::kubectl::locate_kubectl; use crate::state::AppState; use serde::{Deserialize, Serialize}; use serde_yaml::Value; use std::net::TcpListener; use std::sync::Arc; use tauri::State; use tokio::process::Command; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClusterInfo { pub id: String, pub name: String, pub context: String, pub cluster_url: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortForwardRequest { pub cluster_id: String, pub namespace: String, pub pod: String, pub container_port: u16, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortForwardResponse { pub id: String, pub cluster_id: String, pub namespace: String, pub pod: String, pub container_port: u16, pub local_port: u16, pub status: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PodInfo { pub name: String, pub status: String, pub ready: String, pub age: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClusterConnectionStatus { pub status: ClusterConnectionState, pub context: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ClusterConnectionState { Connected, Disconnected { error: String }, } #[tauri::command] pub async fn add_cluster( id: String, name: String, kubeconfig_content: String, state: State<'_, AppState>, ) -> Result { if kubeconfig_content.trim().is_empty() { return Err("Kubeconfig content cannot be empty".to_string()); } let context = extract_context(&kubeconfig_content)?; let server_url = extract_server_url(&kubeconfig_content)?; let kubeconfig_arc = Arc::new(kubeconfig_content.clone()); let client = ClusterClient::new( id.clone(), name.clone(), context.clone(), server_url.clone(), kubeconfig_arc, ); { let mut clusters = state.clusters.lock().await; clusters.insert(id.clone(), client); } Ok(ClusterInfo { id, name, context, cluster_url: server_url, }) } fn extract_context(content: &str) -> Result { let value: Value = serde_yaml::from_str(content).map_err(|e| format!("Invalid kubeconfig YAML: {}", e))?; let contexts = value .get("contexts") .and_then(|c| c.as_sequence()) .ok_or("Missing 'contexts' field in kubeconfig")?; if contexts.is_empty() { return Err("No contexts found in kubeconfig".to_string()); } let first_context = contexts[0].get("name").and_then(|n| n.as_str()); first_context .map(|s| s.to_string()) .ok_or_else(|| "Context name not found".to_string()) } fn extract_server_url(content: &str) -> Result { let value: Value = serde_yaml::from_str(content).map_err(|e| format!("Invalid kubeconfig YAML: {}", e))?; let clusters = value .get("clusters") .and_then(|c| c.as_sequence()) .ok_or("Missing 'clusters' field in kubeconfig")?; if clusters.is_empty() { return Err("No clusters found in kubeconfig".to_string()); } let cluster = &clusters[0]; let server = cluster .get("cluster") .and_then(|c| c.get("server")) .and_then(|s| s.as_str()); server .map(|s| s.to_string()) .ok_or_else(|| "Server URL not found in cluster".to_string()) } #[tauri::command] pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> { let mut clusters = state.clusters.lock().await; if clusters.remove(&id).is_none() { return Err(format!("Cluster {id} not found")); } Ok(()) } #[tauri::command] pub async fn list_clusters(state: State<'_, AppState>) -> Result, String> { let clusters = state.clusters.lock().await; let cluster_list: Vec = clusters .values() .map(|c| ClusterInfo { id: c.id.clone(), name: c.name.clone(), context: c.context.clone(), cluster_url: c.server_url.clone(), }) .collect(); Ok(cluster_list) } #[tauri::command] pub async fn test_cluster_connection( cluster_id: String, state: State<'_, AppState>, ) -> Result { let clusters = state.clusters.lock().await; let cluster = clusters .get(&cluster_id) .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; // Write kubeconfig to temp file let temp_dir = std::env::temp_dir(); let temp_path = temp_dir.join(format!("kubeconfig-{}.yaml", cluster_id)); std::fs::write(&temp_path, kubeconfig_content) .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; // Run kubectl cluster-info let kubectl_path = locate_kubectl()?; let output = Command::new(kubectl_path) .arg("cluster-info") .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) .env("KUBERNETES_CONTEXT", context) .output() .await .map_err(|e| format!("Failed to execute kubectl: {e}"))?; let status = if output.status.success() { ClusterConnectionState::Connected } else { let stderr = String::from_utf8_lossy(&output.stderr); ClusterConnectionState::Disconnected { error: stderr.to_string(), } }; Ok(ClusterConnectionStatus { status, context: context.clone(), }) } #[tauri::command] pub async fn discover_pods( cluster_id: String, namespace: String, state: State<'_, AppState>, ) -> Result, String> { let clusters = state.clusters.lock().await; let cluster = clusters .get(&cluster_id) .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; // Write kubeconfig to temp file let temp_dir = std::env::temp_dir(); let temp_path = temp_dir.join(format!("kubeconfig-{}-pods.yaml", cluster_id)); std::fs::write(&temp_path, kubeconfig_content) .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; // Run kubectl get pods let kubectl_path = locate_kubectl()?; let output = Command::new(kubectl_path) .arg("get") .arg("pods") .arg("-n") .arg(&namespace) .arg("-o") .arg("jsonpath={.items[*].metadata.name}") .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) .env("KUBERNETES_CONTEXT", context) .output() .await .map_err(|e| format!("Failed to execute kubectl: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("Failed to list pods: {}", stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); let pod_names: Vec<&str> = stdout.split_whitespace().collect(); // For now, return basic pod info - in production, parse full JSON output let pods: Vec = pod_names .into_iter() .map(|name| PodInfo { name: name.to_string(), status: "Unknown".to_string(), ready: "N/A".to_string(), age: "N/A".to_string(), }) .collect(); Ok(pods) } #[tauri::command] pub async fn start_port_forward( request: PortForwardRequest, state: State<'_, AppState>, ) -> Result { let session_id = uuid::Uuid::now_v7().to_string(); let clusters = state.clusters.lock().await; let cluster = clusters .get(&request.cluster_id) .ok_or_else(|| format!("Cluster {} not found", request.cluster_id))?; let cluster_name = cluster.name.clone(); let kubeconfig_content = cluster.kubeconfig_content.clone(); // Allocate local port using TcpListener::bind("127.0.0.1:0") let listener = TcpListener::bind("127.0.0.1:0") .map_err(|e| format!("Failed to allocate local port: {e}"))?; let local_port = listener .local_addr() .map_err(|e| format!("Failed to get local port address: {e}"))? .port(); // Drop the listener - the port is now reserved for kubectl drop(listener); tracing::info!( session_id = %session_id, cluster_id = %request.cluster_id, namespace = %request.namespace, pod = %request.pod, container_port = request.container_port, local_port, "Allocating local port for port-forward" ); // Write kubeconfig to temp file let temp_dir = std::env::temp_dir(); let temp_path = temp_dir.join(format!("kubeconfig-{}.yaml", request.cluster_id)); std::fs::write(&temp_path, kubeconfig_content.as_ref()) .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; // Build kubectl command let kubectl_path = locate_kubectl()?; let args = vec![ "port-forward".to_string(), format!("pod/{}", request.pod), format!("{}:{}", local_port, request.container_port), "-n".to_string(), request.namespace.clone(), ]; tracing::info!( session_id = %session_id, command = ?args, "Spawning kubectl port-forward subprocess" ); // Spawn kubectl subprocess let child = Command::new(kubectl_path) .args(&args) .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) .env("KUBERNETES_CONTEXT", &cluster.context) .spawn() .map_err(|e| format!("Failed to spawn kubectl: {e}"))?; let child_mutex = Arc::new(std::sync::Mutex::new(child)); // Create session with allocated port let _session = crate::kube::PortForwardSession::new(PortForwardSessionConfig { id: session_id.clone(), cluster_id: request.cluster_id.clone(), cluster_name, namespace: request.namespace.clone(), pod: request.pod.clone(), container: None, ports: vec![request.container_port], local_ports: vec![local_port], }); // Store child handle in session { let mut port_forwards = state.port_forwards.lock().await; let session_mut = port_forwards.get_mut(&session_id).unwrap(); session_mut.kubectl_child = Some(child_mutex); } tracing::info!( session_id = %session_id, local_port, "Port-forward session started" ); Ok(PortForwardResponse { id: session_id, cluster_id: request.cluster_id, namespace: request.namespace, pod: request.pod, container_port: request.container_port, local_port, status: "Active".to_string(), }) } #[tauri::command] pub async fn stop_port_forward(id: String, state: State<'_, AppState>) -> Result<(), String> { let mut port_forwards = state.port_forwards.lock().await; if let Some(session) = port_forwards.get_mut(&id) { session.stop(); tracing::info!(session_id = %id, "Port-forward session stopped"); Ok(()) } else { Err(format!("Port forward session {id} not found")) } } #[tauri::command] pub async fn list_port_forwards( state: State<'_, AppState>, ) -> Result, String> { let port_forwards = state.port_forwards.lock().await; let forwards: Vec = port_forwards .values() .map(|s| PortForwardResponse { id: s.id.clone(), cluster_id: s.cluster_id.clone(), namespace: s.namespace.clone(), pod: s.pod.clone(), container_port: s.ports.first().copied().unwrap_or(0), local_port: s.local_ports.first().copied().unwrap_or(0), status: match s.status { crate::kube::PortForwardStatus::Active => "Active".to_string(), crate::kube::PortForwardStatus::Stopped => "Stopped".to_string(), crate::kube::PortForwardStatus::Error(ref e) => e.clone(), }, }) .collect(); Ok(forwards) } #[tauri::command] pub async fn delete_port_forward(id: String, state: State<'_, AppState>) -> Result<(), String> { let mut port_forwards = state.port_forwards.lock().await; if port_forwards.remove(&id).is_none() { return Err(format!("Port forward session {id} not found")); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_cluster_info_serialization() { let info = ClusterInfo { id: "cluster-1".to_string(), name: "Production".to_string(), context: "prod-context".to_string(), cluster_url: "https://k8s.example.com".to_string(), }; let json = serde_json::to_string(&info).unwrap(); let parsed: ClusterInfo = serde_json::from_str(&json).unwrap(); assert_eq!(info.id, parsed.id); assert_eq!(info.name, parsed.name); assert_eq!(info.context, parsed.context); assert_eq!(info.cluster_url, parsed.cluster_url); } #[test] fn test_cluster_connection_state_serialization() { let connected = ClusterConnectionState::Connected; let json = serde_json::to_string(&connected).unwrap(); let parsed: ClusterConnectionState = serde_json::from_str(&json).unwrap(); assert!(matches!(parsed, ClusterConnectionState::Connected)); let disconnected = ClusterConnectionState::Disconnected { error: "connection refused".to_string(), }; let json = serde_json::to_string(&disconnected).unwrap(); let parsed: ClusterConnectionState = serde_json::from_str(&json).unwrap(); assert!(matches!( parsed, ClusterConnectionState::Disconnected { .. } )); } #[test] fn test_port_forward_request_serialization() { let request = PortForwardRequest { cluster_id: "cluster-1".to_string(), namespace: "default".to_string(), pod: "my-pod-abc123".to_string(), container_port: 8080, }; let json = serde_json::to_string(&request).unwrap(); let parsed: PortForwardRequest = serde_json::from_str(&json).unwrap(); assert_eq!(request.cluster_id, parsed.cluster_id); assert_eq!(request.namespace, parsed.namespace); assert_eq!(request.pod, parsed.pod); assert_eq!(request.container_port, parsed.container_port); } }