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

256 lines
8.1 KiB
Rust
Raw Normal View History

// 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;
#[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();
fix(kube): use current-context for kubectl auth; fix SelectValue label display ## kubectl credentials still failing after --context fix Root cause: both extract_context() (kube.rs) and upload_kubeconfig() (shell.rs) ignored the kubeconfig's current-context field and always picked contexts[0] from the contexts array. If a kubeconfig has multiple contexts and current-context points to entry N>0, we silently used the wrong context — one that may have empty or expired credentials — causing the 401 "the server has asked for the client to provide credentials" error on every kubectl call. Fixes: - extract_context(): read current-context field first; fall back to contexts[0] only when current-context is absent or empty. - extract_current_context_name(): new helper in kubeconfig.rs using the same line-scanner approach as parse_kubeconfig_contexts (no extra dependencies). - upload_kubeconfig(): use current-context to select the matching context entry when storing context name in kubeconfig_files; falls back to first entry. NOTE: existing kubeconfig rows in the database have the old (wrong) context stored. Re-uploading kubeconfig files after deploying this build will fix them. ## Cluster dropdown still showing UUID Root cause: SelectValue rendered ctx.value (the raw UUID passed to SelectItem's value prop) instead of the display label (SelectItem's children). The custom Select component had no mechanism to mirror a selected item's children into the trigger area. Fix: Select now builds a value→label Map by walking the children tree at render time (collectLabels). The map is memoised on children. SelectValue reads the display label from the map; if found, shows the label; otherwise falls back to the raw value so existing behaviour is preserved for callers that don't need it.
2026-06-08 00:40:53 +00:00
// 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)?;
fix(kube): use current-context for kubectl auth; fix SelectValue label display ## kubectl credentials still failing after --context fix Root cause: both extract_context() (kube.rs) and upload_kubeconfig() (shell.rs) ignored the kubeconfig's current-context field and always picked contexts[0] from the contexts array. If a kubeconfig has multiple contexts and current-context points to entry N>0, we silently used the wrong context — one that may have empty or expired credentials — causing the 401 "the server has asked for the client to provide credentials" error on every kubectl call. Fixes: - extract_context(): read current-context field first; fall back to contexts[0] only when current-context is absent or empty. - extract_current_context_name(): new helper in kubeconfig.rs using the same line-scanner approach as parse_kubeconfig_contexts (no extra dependencies). - upload_kubeconfig(): use current-context to select the matching context entry when storing context name in kubeconfig_files; falls back to first entry. NOTE: existing kubeconfig rows in the database have the old (wrong) context stored. Re-uploading kubeconfig files after deploying this build will fix them. ## Cluster dropdown still showing UUID Root cause: SelectValue rendered ctx.value (the raw UUID passed to SelectItem's value prop) instead of the display label (SelectItem's children). The custom Select component had no mechanism to mirror a selected item's children into the trigger area. Fix: Select now builds a value→label Map by walking the children tree at render time (collectLabels). The map is memoised on children. SelectValue reads the display label from the map; if found, shows the label; otherwise falls back to the raw value so existing behaviour is preserved for callers that don't need it.
2026-06-08 00:40:53 +00:00
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,
}),
}
}
fix(classifier): fix 3 safety bugs, extract const arrays, make tier UI dynamic Bug 1 — Dead multi-word tier3 entries / missing single-token commands parse_single_command() extracts only the first token as `command`, so multi-word entries like "kill -9", "init 0", "service stop" in the tier3 array never matched. Adding the single-token forms "kill", "pkill", "killall", "init" to TIER3_COMMANDS ensures these commands are always denied. Removed all dead multi-word entries. Bug 2 — systemctl Tier 1 special case was dead code systemctl was not in tier1_general, so the block that was supposed to auto-execute `systemctl status` never ran. Moved systemctl handling into its own block (TIER1_SYSTEMCTL_SUBCOMMANDS / TIER2_SYSTEMCTL_SUBCOMMANDS) evaluated before the general tier checks. status, is-active, is-enabled, list-units, list-unit-files → Tier 1; all others → Tier 2. Bug 3 — ldapmodify / ldapdelete / ldapadd misclassified as Tier 1 Both appeared in the old tier1_general and tier2_general arrays; the tier1 check ran first, so LDAP write operations auto-executed. Removed them from tier1. ldapsearch (read-only) remains Tier 1. Dynamic Safety Architecture UI Extracted all tier classification arrays to module-level pub const slices (TIER3_COMMANDS, TIER1_KUBECTL_SUBCOMMANDS, etc.) so both the classifier logic and a new get_classifier_rules() Tauri command share a single source of truth. ShellExecution.tsx now calls getClassifierRulesCmd() on mount and renders the actual command lists in collapsible per-tier cards — any change to the const arrays is automatically reflected in the UI with no manual documentation update needed. Also fixes the cargo fmt CI failure introduced in the previous commit (ClusterClient::new call reformatted to a single line).
2026-06-07 23:15:42 +00:00
/// 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()
}