tftsr-devops_investigation/src-tauri/src/commands/kube.rs

487 lines
14 KiB
Rust
Raw Normal View History

use crate::kube::portforward::PortForwardSessionConfig;
2026-06-06 19:00:09 +00:00
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<ClusterInfo, String> {
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<String, String> {
2026-06-06 18:30:35 +00:00
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<String, String> {
2026-06-06 18:30:35 +00:00
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]
2026-06-06 17:04:21 +00:00
pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> {
let mut clusters = state.clusters.lock().await;
2026-06-06 17:04:21 +00:00
if clusters.remove(&id).is_none() {
return Err(format!("Cluster {id} not found"));
}
Ok(())
}
#[tauri::command]
2026-06-06 17:04:21 +00:00
pub async fn list_clusters(state: State<'_, AppState>) -> Result<Vec<ClusterInfo>, String> {
let clusters = state.clusters.lock().await;
2026-06-06 17:04:21 +00:00
let cluster_list: Vec<ClusterInfo> = 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<ClusterConnectionStatus, 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-{}.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<Vec<PodInfo>, 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<PodInfo> = 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<PortForwardResponse, String> {
let session_id = uuid::Uuid::now_v7().to_string();
2026-06-06 17:04:21 +00:00
let clusters = state.clusters.lock().await;
2026-06-06 18:30:35 +00:00
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]
2026-06-06 17:04:21 +00:00
pub async fn stop_port_forward(id: String, state: State<'_, AppState>) -> Result<(), String> {
let mut port_forwards = state.port_forwards.lock().await;
2026-06-06 17:04:21 +00:00
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<Vec<PortForwardResponse>, String> {
let port_forwards = state.port_forwards.lock().await;
2026-06-06 17:04:21 +00:00
let forwards: Vec<PortForwardResponse> = 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);
}
}