feat(kube): Implement complete kubectl port-forward runtime #72

Merged
sarman merged 18 commits from feature/kubernetes-management into master 2026-06-07 01:59:39 +00:00
4 changed files with 119 additions and 11 deletions
Showing only changes of commit 7b77511bdb - Show all commits

View File

@ -1,8 +1,8 @@
use tauri::State;
use crate::db::models::{
AiConversation, AiMessage, ImageAttachment, Issue, IssueDetail, IssueFilter, IssueSummary,
IssueUpdate, LogFile, ResolutionStep, TimelineEvent,
AiConversation, AiMessage, Cluster, ImageAttachment, Issue, IssueDetail, IssueFilter,
IssueSummary, IssueUpdate, LogFile, PortForward, ResolutionStep, TimelineEvent,
};
use crate::state::AppState;
@ -805,3 +805,93 @@ mod tests {
assert_eq!(results[0], "issue-1");
}
}
// ─── Kubernetes Cluster CRUD ────────────────────────────────────────────────
use rusqlite::ffi;
#[tauri::command]
pub async fn load_clusters(state: State<'_, AppState>) -> Result<Vec<Cluster>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = db
.prepare(
"SELECT id, name, context, server_url, kubeconfig_id, created_at, updated_at \
FROM clusters ORDER BY name ASC",
)
.map_err(|e| e.to_string())?;
let clusters: Vec<Cluster> = stmt
.query_map([], |row| {
Ok(Cluster {
id: row.get(0)?,
name: row.get(1)?,
context: row.get(2)?,
server_url: row.get(3)?,
kubeconfig_id: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(clusters)
}
// ─── Port Forward CRUD ──────────────────────────────────────────────────────
#[tauri::command]
pub async fn load_port_forwards(state: State<'_, AppState>) -> Result<Vec<PortForward>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = db
.prepare(
"SELECT id, cluster_id, namespace, pod, container, ports, local_ports, status, error_message, created_at, updated_at \
FROM port_forwards ORDER BY created_at ASC",
)
.map_err(|e| e.to_string())?;
let port_forwards: Vec<PortForward> = stmt
.query_map([], |row| {
let ports_str: String = row.get(5)?;
let local_ports_str: String = row.get(6)?;
let ports: Vec<u16> = match serde_json::from_str(&ports_str) {
Ok(v) => v,
Err(e) => {
return Err(rusqlite::Error::SqliteFailure(
ffi::Error::new(ffi::SQLITE_ERROR),
Some(format!("Failed to parse ports: {e}")),
))
}
};
let local_ports: Vec<u16> = match serde_json::from_str(&local_ports_str) {
Ok(v) => v,
Err(e) => {
return Err(rusqlite::Error::SqliteFailure(
ffi::Error::new(ffi::SQLITE_ERROR),
Some(format!("Failed to parse local_ports: {e}")),
))
}
};
Ok(PortForward {
id: row.get(0)?,
cluster_id: row.get(1)?,
namespace: row.get(2)?,
pod: row.get(3)?,
container: row.get(4)?,
ports,
local_ports,
status: row.get(7)?,
error_message: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(port_forwards)
}

View File

@ -148,13 +148,20 @@ fn extract_server_url(content: &str) -> Result<String, String> {
#[tauri::command]
pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> {
// Delete cluster from database (cascade will delete port_forwards)
{
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute("DELETE FROM clusters WHERE id = ?1", [&id])
.map_err(|e| format!("Failed to delete cluster: {e}"))?;
}
let mut clusters = state.clusters.lock().await;
if clusters.remove(&id).is_none() {
return Err(format!("Cluster {id} not found"));
}
// Cascade delete: remove all port forwards for this cluster
// Cascade delete: remove all port forwards for this cluster from memory
let mut port_forwards = state.port_forwards.lock().await;
let session_ids_to_remove: Vec<String> = port_forwards
.iter()
@ -163,7 +170,9 @@ pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<()
.collect();
for session_id in session_ids_to_remove {
port_forwards.remove(&session_id);
if let Some(mut session) = port_forwards.remove(&session_id) {
session.close().await;
}
}
Ok(())
@ -202,7 +211,7 @@ pub async fn test_cluster_connection(
// Write kubeconfig to temp file and ensure cleanup even on panic
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("kubeconfig-{}.yaml", cluster_id));
// Create cleanup struct BEFORE writing - ensures cleanup happens even on panic
struct TempFileCleanup(std::path::PathBuf);
impl Drop for TempFileCleanup {
@ -258,7 +267,7 @@ pub async fn discover_pods(
// Write kubeconfig to temp file and ensure cleanup even on panic
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("kubeconfig-{}-pods.yaml", cluster_id));
// Create cleanup struct BEFORE writing - ensures cleanup happens even on panic
struct TempFileCleanup(std::path::PathBuf);
impl Drop for TempFileCleanup {
@ -475,7 +484,7 @@ pub async fn start_port_forward(
// Write kubeconfig to temp file and ensure cleanup even on panic
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("kubeconfig-{}.yaml", request.cluster_id));
// Create cleanup struct BEFORE writing - ensures cleanup happens even on panic
struct TempFileCleanup(std::path::PathBuf);
impl Drop for TempFileCleanup {
@ -591,6 +600,13 @@ pub async fn list_port_forwards(
#[tauri::command]
pub async fn delete_port_forward(id: String, state: State<'_, AppState>) -> Result<(), String> {
// Delete from database
{
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute("DELETE FROM port_forwards WHERE id = ?1", [&id])
.map_err(|e| format!("Failed to delete port forward: {e}"))?;
}
let mut port_forwards = state.port_forwards.lock().await;
if let Some(mut session) = port_forwards.remove(&id) {
@ -698,15 +714,15 @@ mod tests {
#[tauri::command]
pub async fn shutdown_port_forwards(state: State<'_, AppState>) -> Result<(), String> {
let mut port_forwards = state.port_forwards.lock().await;
// Close all active port forward sessions
let session_ids: Vec<String> = port_forwards.keys().cloned().collect();
for session_id in session_ids {
if let Some(mut session) = port_forwards.remove(&session_id) {
session.close().await;
}
}
Ok(())
}

View File

@ -85,7 +85,7 @@ impl PortForwardSession {
let join_handle = tokio::spawn(async move {
// Take the child from the Arc
let mut child = child_for_task.lock().await.take().expect("Child not set");
// Wait for the child process to complete
// This is safe because we're in an async context
let result = child.wait().await;

View File

@ -95,6 +95,8 @@ pub fn run() {
commands::db::update_five_why,
commands::db::add_timeline_event,
commands::db::get_timeline_events,
commands::db::load_clusters,
commands::db::load_port_forwards,
// Analysis / PII
commands::analysis::upload_log_file,
commands::analysis::upload_log_file_by_content,