Some checks failed
Test / frontend-typecheck (pull_request) Successful in 1m40s
Test / frontend-tests (pull_request) Successful in 1m42s
PR Review Automation / review (pull_request) Has been cancelled
Test / rust-fmt-check (pull_request) Has been cancelled
Test / rust-clippy (pull_request) Has been cancelled
Test / rust-tests (pull_request) Has been cancelled
Add `path_str()` to `KubeconfigGuard` so the path can be passed to `SessionParams` without consuming the guard. Both `start_pty_exec_session` and `start_pty_attach_session` now hold the guard live until `start_exec/attach_session` returns `Ok`, then disarm it. Previously `disarm()` was called before the session-start call, meaning a kubeconfig temp file would leak if PTY spawn or session registration failed after the guard was consumed.
517 lines
17 KiB
Rust
517 lines
17 KiB
Rust
// Shell Command Execution Tauri Commands
|
|
//
|
|
// This module provides Tauri commands for the frontend to:
|
|
// - Manage kubeconfig files (upload, list, activate, delete)
|
|
// - Respond to shell command approval requests
|
|
// - List command execution history
|
|
// - Check kubectl installation status
|
|
|
|
use crate::shell::KubeconfigInfo;
|
|
use crate::state::{AppState, ApprovalResponse};
|
|
use rusqlite::params;
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::State;
|
|
|
|
/// RAII guard for a temp kubeconfig file. Removes the file when dropped
|
|
/// unless `disarm()` has been called — used on the error path of session
|
|
/// start so the file isn't leaked if kubectl resolution or session
|
|
/// registration fails after we've written it. On the success path we call
|
|
/// `disarm()` and the PTY session itself becomes responsible for the file's
|
|
/// lifetime (it lives in `std::env::temp_dir()` which is OS-cleaned).
|
|
struct KubeconfigGuard {
|
|
path: Option<std::path::PathBuf>,
|
|
}
|
|
|
|
impl KubeconfigGuard {
|
|
fn new(path: std::path::PathBuf) -> Self {
|
|
Self { path: Some(path) }
|
|
}
|
|
|
|
/// Return the path as a string without transferring ownership.
|
|
fn path_str(&self) -> String {
|
|
self.path
|
|
.as_ref()
|
|
.expect("KubeconfigGuard path already taken")
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
}
|
|
|
|
/// Transfer ownership: caller is now responsible for the file.
|
|
/// Returns the path string for use with the PTY session.
|
|
fn disarm(mut self) -> String {
|
|
let path = self.path.take().expect("KubeconfigGuard already disarmed");
|
|
path.to_string_lossy().into_owned()
|
|
}
|
|
}
|
|
|
|
impl Drop for KubeconfigGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(path) = self.path.take() {
|
|
if let Err(e) = std::fs::remove_file(&path) {
|
|
if e.kind() != std::io::ErrorKind::NotFound {
|
|
tracing::warn!("Failed to remove temp kubeconfig {}: {}", path.display(), e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CommandExecution {
|
|
pub id: String,
|
|
pub command: String,
|
|
pub tier: i32,
|
|
pub approval_status: String,
|
|
pub exit_code: Option<i32>,
|
|
pub stdout: Option<String>,
|
|
pub stderr: Option<String>,
|
|
pub execution_time_ms: Option<i64>,
|
|
pub executed_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct KubectlStatus {
|
|
pub installed: bool,
|
|
pub path: Option<String>,
|
|
pub version: Option<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn upload_kubeconfig(
|
|
name: String,
|
|
content: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<String, String> {
|
|
// Generate ID
|
|
let id = uuid::Uuid::now_v7().to_string();
|
|
|
|
// Parse kubeconfig to extract context.
|
|
// Use current-context to select the right entry — the user's kubeconfig may
|
|
// have multiple contexts and current-context is the one kubectl defaults to.
|
|
let contexts = crate::shell::kubeconfig::parse_kubeconfig_contexts(&content)?;
|
|
|
|
let current_context_name = crate::shell::kubeconfig::extract_current_context_name(&content);
|
|
|
|
let context = if let Some(ref current) = current_context_name {
|
|
contexts
|
|
.iter()
|
|
.find(|c| &c.name == current)
|
|
.or_else(|| contexts.first())
|
|
.ok_or_else(|| "No contexts found in kubeconfig".to_string())?
|
|
} else {
|
|
contexts
|
|
.first()
|
|
.ok_or_else(|| "No contexts found in kubeconfig".to_string())?
|
|
};
|
|
|
|
// Encrypt content
|
|
let encrypted_content = crate::integrations::auth::encrypt_token(&content)?;
|
|
|
|
// Store in database
|
|
{
|
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
db.execute(
|
|
"INSERT INTO kubeconfig_files (id, name, encrypted_content, context, cluster_url, is_active)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, 0)",
|
|
params![&id, &name, &encrypted_content, &context.name, &context.cluster_url],
|
|
).map_err(|e| format!("Failed to store kubeconfig: {e}"))?;
|
|
}
|
|
|
|
Ok(id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn list_kubeconfigs(state: State<'_, AppState>) -> Result<Vec<KubeconfigInfo>, String> {
|
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
|
|
let mut stmt = db
|
|
.prepare("SELECT id, name, context, cluster_url, is_active FROM kubeconfig_files ORDER BY uploaded_at DESC")
|
|
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
|
|
|
|
let configs = stmt
|
|
.query_map([], |row| {
|
|
Ok(KubeconfigInfo {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
context: row.get(2)?,
|
|
cluster_url: row.get(3)?,
|
|
is_active: row.get::<_, i32>(4)? != 0,
|
|
})
|
|
})
|
|
.map_err(|e| format!("Failed to query kubeconfigs: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("Failed to collect results: {e}"))?;
|
|
|
|
Ok(configs)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn activate_kubeconfig(id: String, state: State<'_, AppState>) -> Result<(), String> {
|
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
|
|
// Deactivate all configs
|
|
db.execute("UPDATE kubeconfig_files SET is_active = 0", [])
|
|
.map_err(|e| format!("Failed to deactivate configs: {e}"))?;
|
|
|
|
// Activate the specified config
|
|
db.execute(
|
|
"UPDATE kubeconfig_files SET is_active = 1 WHERE id = ?1",
|
|
params![&id],
|
|
)
|
|
.map_err(|e| format!("Failed to activate config: {e}"))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_kubeconfig(id: String, state: State<'_, AppState>) -> Result<(), String> {
|
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
|
|
db.execute("DELETE FROM kubeconfig_files WHERE id = ?1", params![&id])
|
|
.map_err(|e| format!("Failed to delete kubeconfig: {e}"))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn respond_to_shell_approval(
|
|
approval_id: String,
|
|
decision: String, // "deny", "allow_once", "allow_session"
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
// Retrieve the pending approval channel
|
|
let sender = {
|
|
let mut approvals = state.pending_approvals.lock().await;
|
|
approvals.remove(&approval_id)
|
|
};
|
|
|
|
if let Some(sender) = sender {
|
|
let approved = decision != "deny";
|
|
let response = ApprovalResponse { approved, decision };
|
|
|
|
// Send response
|
|
sender
|
|
.send(response)
|
|
.map_err(|_| "Failed to send approval response".to_string())?;
|
|
|
|
Ok(())
|
|
} else {
|
|
Err("Approval request not found or already responded to".to_string())
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn list_command_executions(
|
|
issue_id: Option<String>,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Vec<CommandExecution>, String> {
|
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
|
|
let (query, params_vec): (String, Vec<String>) = if let Some(issue_id) = issue_id {
|
|
(
|
|
"SELECT id, command, tier, approval_status, exit_code, stdout, stderr, execution_time_ms, executed_at
|
|
FROM command_executions
|
|
WHERE issue_id = ?1
|
|
ORDER BY executed_at DESC
|
|
LIMIT 100".to_string(),
|
|
vec![issue_id],
|
|
)
|
|
} else {
|
|
(
|
|
"SELECT id, command, tier, approval_status, exit_code, stdout, stderr, execution_time_ms, executed_at
|
|
FROM command_executions
|
|
ORDER BY executed_at DESC
|
|
LIMIT 100".to_string(),
|
|
vec![],
|
|
)
|
|
};
|
|
|
|
let mut stmt = db
|
|
.prepare(&query)
|
|
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
|
|
|
|
let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec
|
|
.iter()
|
|
.map(|s| s as &dyn rusqlite::ToSql)
|
|
.collect();
|
|
|
|
let executions = stmt
|
|
.query_map(params_refs.as_slice(), |row| {
|
|
Ok(CommandExecution {
|
|
id: row.get(0)?,
|
|
command: row.get(1)?,
|
|
tier: row.get(2)?,
|
|
approval_status: row.get(3)?,
|
|
exit_code: row.get(4)?,
|
|
stdout: row.get(5)?,
|
|
stderr: row.get(6)?,
|
|
execution_time_ms: row.get(7)?,
|
|
executed_at: row.get(8)?,
|
|
})
|
|
})
|
|
.map_err(|e| format!("Failed to query executions: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("Failed to collect results: {e}"))?;
|
|
|
|
Ok(executions)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn check_kubectl_installed(_state: State<'_, AppState>) -> Result<KubectlStatus, String> {
|
|
match crate::shell::kubectl::locate_kubectl() {
|
|
Ok(path) => {
|
|
// Try to get version
|
|
let version = tokio::process::Command::new(&path)
|
|
.arg("version")
|
|
.arg("--client")
|
|
.arg("--output=json")
|
|
.output()
|
|
.await
|
|
.ok()
|
|
.and_then(|output| {
|
|
if output.status.success() {
|
|
String::from_utf8(output.stdout).ok()
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
Ok(KubectlStatus {
|
|
installed: true,
|
|
path: Some(path.to_string_lossy().to_string()),
|
|
version,
|
|
})
|
|
}
|
|
Err(_) => Ok(KubectlStatus {
|
|
installed: false,
|
|
path: None,
|
|
version: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Return the live classifier rule lists so the UI can render them dynamically.
|
|
/// The data derives directly from the module-level const arrays in classifier.rs,
|
|
/// so any addition or removal there is automatically reflected in the UI.
|
|
#[tauri::command]
|
|
pub fn get_classifier_rules() -> crate::shell::classifier::ClassifierRules {
|
|
crate::shell::classifier::CommandClassifier::get_rules()
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// PTY Session Management Commands
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PtySessionInfo {
|
|
pub id: String,
|
|
pub cluster_id: String,
|
|
pub namespace: String,
|
|
pub pod: String,
|
|
pub container: Option<String>,
|
|
pub session_type: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
/// Start an interactive kubectl exec session with PTY support
|
|
#[tauri::command]
|
|
pub async fn start_pty_exec_session(
|
|
app: tauri::AppHandle,
|
|
state: State<'_, AppState>,
|
|
cluster_id: String,
|
|
namespace: String,
|
|
pod: String,
|
|
container: Option<String>,
|
|
) -> Result<String, String> {
|
|
// Get active kubeconfig — the guard ensures the temp file is removed
|
|
// if anything between here and `disarm()` fails.
|
|
let kubeconfig_guard: Option<KubeconfigGuard> = {
|
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = db
|
|
.prepare("SELECT encrypted_content FROM kubeconfig_files WHERE is_active = 1 LIMIT 1")
|
|
.map_err(|e| format!("Failed to query active kubeconfig: {e}"))?;
|
|
|
|
let encrypted: Option<String> = stmt.query_row([], |row| row.get(0)).ok();
|
|
|
|
if let Some(enc) = encrypted {
|
|
let content = crate::integrations::auth::decrypt_token(&enc)
|
|
.map_err(|e| format!("Failed to decrypt kubeconfig: {e}"))?;
|
|
|
|
// Write to temp file
|
|
let temp_path =
|
|
std::env::temp_dir().join(format!("kubeconfig-{}.yaml", uuid::Uuid::now_v7()));
|
|
std::fs::write(&temp_path, content)
|
|
.map_err(|e| format!("Failed to write kubeconfig: {e}"))?;
|
|
|
|
Some(KubeconfigGuard::new(temp_path))
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
// Locate kubectl — if this fails, the guard cleans up the temp kubeconfig.
|
|
let kubectl_path =
|
|
crate::shell::kubectl::locate_kubectl().map_err(|e| format!("kubectl not found: {e}"))?;
|
|
|
|
// Obtain path string without disarming; the guard remains active so the
|
|
// file is cleaned up if session start fails below.
|
|
let kubeconfig_path = kubeconfig_guard.as_ref().map(|g| g.path_str());
|
|
|
|
// Start session
|
|
let params = crate::shell::session::SessionParams {
|
|
cluster_id,
|
|
namespace,
|
|
pod,
|
|
container,
|
|
kubectl_path: kubectl_path.to_string_lossy().to_string(),
|
|
kubeconfig_path,
|
|
};
|
|
|
|
let session_id = state
|
|
.pty_sessions
|
|
.start_exec_session(app, params)
|
|
.await
|
|
.map_err(|e| format!("Failed to start exec session: {e}"))?;
|
|
|
|
// Session started — disarm the guard so the file outlives this function.
|
|
// The PTY process needs the kubeconfig for the full session duration;
|
|
// temp dir is OS-cleaned on reboot.
|
|
if let Some(g) = kubeconfig_guard {
|
|
g.disarm();
|
|
}
|
|
|
|
Ok(session_id)
|
|
}
|
|
|
|
/// Start an interactive kubectl attach session with PTY support
|
|
#[tauri::command]
|
|
pub async fn start_pty_attach_session(
|
|
app: tauri::AppHandle,
|
|
state: State<'_, AppState>,
|
|
cluster_id: String,
|
|
namespace: String,
|
|
pod: String,
|
|
container: Option<String>,
|
|
) -> Result<String, String> {
|
|
// Get active kubeconfig — the guard ensures the temp file is removed
|
|
// if anything between here and `disarm()` fails.
|
|
let kubeconfig_guard: Option<KubeconfigGuard> = {
|
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = db
|
|
.prepare("SELECT encrypted_content FROM kubeconfig_files WHERE is_active = 1 LIMIT 1")
|
|
.map_err(|e| format!("Failed to query active kubeconfig: {e}"))?;
|
|
|
|
let encrypted: Option<String> = stmt.query_row([], |row| row.get(0)).ok();
|
|
|
|
if let Some(enc) = encrypted {
|
|
let content = crate::integrations::auth::decrypt_token(&enc)
|
|
.map_err(|e| format!("Failed to decrypt kubeconfig: {e}"))?;
|
|
|
|
// Write to temp file
|
|
let temp_path =
|
|
std::env::temp_dir().join(format!("kubeconfig-{}.yaml", uuid::Uuid::now_v7()));
|
|
std::fs::write(&temp_path, content)
|
|
.map_err(|e| format!("Failed to write kubeconfig: {e}"))?;
|
|
|
|
Some(KubeconfigGuard::new(temp_path))
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
// Locate kubectl — if this fails, the guard cleans up the temp kubeconfig.
|
|
let kubectl_path =
|
|
crate::shell::kubectl::locate_kubectl().map_err(|e| format!("kubectl not found: {e}"))?;
|
|
|
|
// Obtain path string without disarming; the guard remains active so the
|
|
// file is cleaned up if session start fails below.
|
|
let kubeconfig_path = kubeconfig_guard.as_ref().map(|g| g.path_str());
|
|
|
|
// Start session
|
|
let params = crate::shell::session::SessionParams {
|
|
cluster_id,
|
|
namespace,
|
|
pod,
|
|
container,
|
|
kubectl_path: kubectl_path.to_string_lossy().to_string(),
|
|
kubeconfig_path,
|
|
};
|
|
|
|
let session_id = state
|
|
.pty_sessions
|
|
.start_attach_session(app, params)
|
|
.await
|
|
.map_err(|e| format!("Failed to start attach session: {e}"))?;
|
|
|
|
// Session started — disarm the guard so the file outlives this function.
|
|
if let Some(g) = kubeconfig_guard {
|
|
g.disarm();
|
|
}
|
|
|
|
Ok(session_id)
|
|
}
|
|
|
|
/// Send stdin data to a PTY session
|
|
#[tauri::command]
|
|
pub async fn send_pty_stdin(
|
|
state: State<'_, AppState>,
|
|
session_id: String,
|
|
data: Vec<u8>,
|
|
) -> Result<(), String> {
|
|
state
|
|
.pty_sessions
|
|
.send_stdin(&session_id, data)
|
|
.await
|
|
.map_err(|e| format!("Failed to send stdin: {e}"))
|
|
}
|
|
|
|
/// Resize a PTY session
|
|
#[tauri::command]
|
|
pub async fn resize_pty_session(
|
|
state: State<'_, AppState>,
|
|
session_id: String,
|
|
rows: u16,
|
|
cols: u16,
|
|
) -> Result<(), String> {
|
|
state
|
|
.pty_sessions
|
|
.resize_session(&session_id, rows, cols)
|
|
.await
|
|
.map_err(|e| format!("Failed to resize session: {e}"))
|
|
}
|
|
|
|
/// Terminate a PTY session
|
|
#[tauri::command]
|
|
pub async fn terminate_pty_session(
|
|
state: State<'_, AppState>,
|
|
session_id: String,
|
|
) -> Result<(), String> {
|
|
state
|
|
.pty_sessions
|
|
.terminate_session(&session_id)
|
|
.await
|
|
.map_err(|e| format!("Failed to terminate session: {e}"))
|
|
}
|
|
|
|
/// List all active PTY sessions
|
|
#[tauri::command]
|
|
pub async fn list_pty_sessions(state: State<'_, AppState>) -> Result<Vec<PtySessionInfo>, String> {
|
|
let sessions = state.pty_sessions.list_sessions().await;
|
|
|
|
Ok(sessions
|
|
.into_iter()
|
|
.map(|s| PtySessionInfo {
|
|
id: s.id,
|
|
cluster_id: s.cluster_id,
|
|
namespace: s.namespace,
|
|
pod: s.pod,
|
|
container: s.container,
|
|
session_type: match s.session_type {
|
|
crate::shell::SessionType::Exec => "exec".to_string(),
|
|
crate::shell::SessionType::Attach => "attach".to_string(),
|
|
},
|
|
created_at: s.created_at.to_rfc3339(),
|
|
})
|
|
.collect())
|
|
}
|