From 8c051ad85d45306c7cf120fb1aaeca4472c25c0d Mon Sep 17 00:00:00 2001 From: jamesgiu Date: Fri, 18 Jul 2025 00:33:06 +1000 Subject: [PATCH 01/10] QK-XX smol refactor --- src/cli.rs | 20 +++ src/{klog.rs => gui.rs} | 238 +++--------------------------------- src/kubectl.rs | 262 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 79 ++---------- 4 files changed, 313 insertions(+), 286 deletions(-) create mode 100644 src/cli.rs rename src/{klog.rs => gui.rs} (63%) create mode 100644 src/kubectl.rs diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..f1a1dc8 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,20 @@ +use std::{fs, process::Command}; + +use crate::kubectl::{self, FoundPod}; + +pub fn open_in_vim(pod: &FoundPod) -> anyhow::Result<()> { + let logs = kubectl::get_pod_logs(pod, false, false).unwrap(); + let name = &pod.name; + let fname = format!("/tmp/klog_{name}"); + fs::write(&fname, logs).expect("Unable to write file"); + let _output = { + Command::new("vim") + .arg(&fname) + .spawn() + .unwrap() + .wait() + .expect("failed to execute process") + }; + + Ok(()) +} \ No newline at end of file diff --git a/src/klog.rs b/src/gui.rs similarity index 63% rename from src/klog.rs rename to src/gui.rs index 4f43e12..7bb3e05 100644 --- a/src/klog.rs +++ b/src/gui.rs @@ -1,5 +1,4 @@ -use std::{fs, io}; -use std::process::{Command, Stdio}; +use std::{io}; use std::time::{Duration, Instant}; use crossterm::event::{DisableMouseCapture, Event, KeyCode}; use crossterm::{event, execute}; @@ -11,8 +10,11 @@ use ratatui::{Frame, Terminal}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::prelude::Stylize; use ratatui::style::{Color, Style}; +use color_eyre::eyre::{Result}; use ratatui::widgets::{Block, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}; -use crate::{find_matching_pod, FoundPod}; + +use crate::kubectl::{self, FoundPod}; +use crate::cli::{self}; #[derive(Default)] struct App { @@ -27,7 +29,7 @@ struct App { pub emoji: String, } -pub(crate) fn klog(target: FoundPod) -> anyhow::Result<()> { +pub fn gui(target: FoundPod) -> Result<()> { // Find pod(s) based on supplied matcher in --all-namespaces // SSH can only be one, present list to user? or default to first @@ -62,210 +64,6 @@ pub(crate) fn klog(target: FoundPod) -> anyhow::Result<()> { Ok(()) } -fn get_pod_logs(pod: &FoundPod, lite: bool, last_container: bool) -> anyhow::Result { - let output = { - Command::new("kubectl") - .arg("logs") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg(if lite {"--tail=500"} else {"--tail=-1"}) - .arg(if last_container {"--previous=true"} else {"--previous=false"}) - .output() - .expect("failed to execute process") - }; - - let logs = String::from_utf8(output.stdout).unwrap().to_string(); - - Ok(logs) -} - -fn describe_pod(pod: &FoundPod) -> anyhow::Result { - let output = { - Command::new("kubectl") - .arg("describe") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .output() - .expect("failed to execute process") - }; - - let describe = String::from_utf8(output.stdout).unwrap().to_string(); - - Ok(describe) -} - -fn get_pods(pod: &FoundPod) -> anyhow::Result { - let output = { - Command::new("kubectl") - .arg("get") - .arg("pods") - .arg("-n") - .arg(&pod.namespace) - .arg("--sort-by=.status.startTime") - .arg("--no-headers") - .stdout(Stdio::piped()) - .spawn() - .unwrap() - }; - - let tac = { - Command::new("tac") - .stdin(Stdio::from(output.stdout.unwrap())) - .output() - .expect("failed to execute process") - }; - - let pods = String::from_utf8(tac.stdout).unwrap(). - replace("Running", "πŸƒ Running"). - replace("Error", "❌ Error"). - replace("Completed", "βœ… Completed"). - replace("Terminating", "πŸ’€οΈ Terminating"). - replace("CrashLoopBackOff", "πŸ”₯ CrashLoopBackOff"). - replace("ImagePullBackOff", "πŸ‘» ImagePullBackOff"). - replace("ContainerCreating", "✨️ ContainerCreating") - .to_string(); - - Ok(pods) -} - -fn get_all(pod: &FoundPod) -> anyhow::Result { - let output = { - Command::new("kubectl") - .arg("get") - .arg("all") - .arg("-n") - .arg(&pod.namespace) - .arg("--no-headers") - .output() - }; - - let all = String::from_utf8(output.unwrap().stdout).unwrap().to_string(); - - Ok(all) -} - -fn edit_deployment(pod: &FoundPod) -> anyhow::Result<()> { - let output = { - Command::new("kubectl") - .arg("edit") - .arg("deployment") - .arg(&pod.deployment) - .arg("-n") - .arg(&pod.namespace) - .spawn() - .unwrap() - .wait() - .expect("failed to execute process") - }; - - Ok(()) -} - -fn delete_pod(pod: &FoundPod) -> anyhow::Result { - let output = { - Command::new("kubectl") - .arg("delete") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("--wait=false") - .output() - .expect("failed to execute process") - }; - - let delete = String::from_utf8(output.stdout).unwrap().to_string(); - - Ok(delete) -} - -fn exec_into_pod(pod: &FoundPod) -> anyhow::Result<()> { - let _output = { - Command::new("kubectl") - .arg("exec") - .arg("--stdin") - .arg("--tty") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("--") - .arg("/bin/sh") - .spawn() - .unwrap() - .wait() - .expect("failed to execute process") - }; - - Ok(()) -} - -fn debug_pod(pod: &FoundPod) -> anyhow::Result<()> { - // Get image name. - let image_name = String::from_utf8(Command::new("kubectl") - .arg("get") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("-o=jsonpath={.spec.containers[0].image}") - .output() - .unwrap() - .stdout).unwrap().replace("[ ", ""); - - let container_name = String::from_utf8(Command::new("kubectl") - .arg("get") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("-o=jsonpath={.spec.containers[0].name}") - .output() - .unwrap() - .stdout).unwrap().replace("[ ", ""); - - - print!("{}", &image_name); - - let _output = { - Command::new("kubectl") - .arg("debug") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("-it") - .arg(format!("--image={}", &image_name)) - .arg(format!("--target={}", &container_name)) - .arg("--") - .arg("sh") - .spawn() - .unwrap() - .wait() - .expect("failed to execute process") - }; - - Ok(()) -} - - -fn open_in_vim(pod: &FoundPod) -> anyhow::Result<()> { - let logs = get_pod_logs(pod, false, false).unwrap(); - let name = &pod.name; - let fname = format!("/tmp/klog_{name}"); - fs::write(&fname, logs).expect("Unable to write file"); - let _output = { - Command::new("vim") - .arg(&fname) - .spawn() - .unwrap() - .wait() - .expect("failed to execute process") - }; - - Ok(()) -} fn run_app( terminal: &mut Terminal, mut app: App, @@ -276,7 +74,7 @@ fn run_app( let mut fetch_prev_container_logs = false; let mut delete_pod_next_tick = false; let mut reset_scroll = true; - let mut text = get_pod_logs(&app.target_pod, true, false).unwrap(); + let mut text = kubectl::get_pod_logs(&app.target_pod, true, false).unwrap(); let icons = ["🐝", "πŸ¦€", "πŸ‹", "🐧", "πŸ¦•", "🦐", "🐬", "🦞", "πŸ€–", "🐀", "πŸͺΏ"]; // Create a random number generator let mut rng = rand::rng(); @@ -295,13 +93,13 @@ fn run_app( } if fetch_prev_container_logs { - text = get_pod_logs(&app.target_pod, true, true).unwrap(); + text = kubectl::get_pod_logs(&app.target_pod, true, true).unwrap(); fetch_prev_container_logs = false; reset_scroll = true; } if fetch_new_logs { - text = get_pod_logs(&app.target_pod, true, false).unwrap(); + text = kubectl::get_pod_logs(&app.target_pod, true, false).unwrap(); fetch_new_logs = false; reset_scroll = true; } @@ -309,7 +107,7 @@ fn run_app( if delete_pod_next_tick { text = text + "\nDeleted :(. Press 'q' to quit."; app.show_pod_deleted_pop_up = true; - delete_pod(&app.target_pod).unwrap(); + kubectl::delete_pod(&app.target_pod).unwrap(); delete_pod_next_tick = false; reset_scroll = true; } @@ -330,7 +128,7 @@ fn run_app( } KeyCode::Enter => { app.new_pod_search_pop_up = false; - app.target_pod = find_matching_pod(app.input_text.as_str()).unwrap(); + app.target_pod = kubectl::find_matching_pod(app.input_text.as_str()).unwrap(); fetch_new_logs = true; app.vertical_scroll = 0; app.input_text.clear(); @@ -353,35 +151,35 @@ fn run_app( delete_pod_next_tick = true; }, KeyCode::Char('d') => { - text = describe_pod(&app.target_pod).unwrap(); + text = kubectl::describe_pod(&app.target_pod).unwrap(); app.vertical_scroll = 0; }, KeyCode::Char('E') => { terminal.clear().unwrap(); - edit_deployment(&app.target_pod).unwrap(); + kubectl::edit_deployment(&app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('w') => { - text = get_pods(&app.target_pod).unwrap(); + text = kubectl::get_pods(&app.target_pod).unwrap(); app.vertical_scroll = 0; }, KeyCode::Char('W') => { - text = get_all(&app.target_pod).unwrap(); + text = kubectl::get_all(&app.target_pod).unwrap(); app.vertical_scroll = 0; }, KeyCode::Char('e') => { terminal.clear().unwrap(); - exec_into_pod(&app.target_pod).unwrap(); + kubectl::exec_into_pod(&app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('b') => { terminal.clear().unwrap(); - debug_pod(&app.target_pod).unwrap(); + kubectl::debug_pod(&app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('v') => { terminal.clear().unwrap(); - open_in_vim(&app.target_pod).unwrap(); + cli::open_in_vim(&app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('l') => { diff --git a/src/kubectl.rs b/src/kubectl.rs new file mode 100644 index 0000000..5ef5663 --- /dev/null +++ b/src/kubectl.rs @@ -0,0 +1,262 @@ +use std::process::{Command, Stdio}; + +use color_eyre::eyre::{eyre, Context, Result}; +use regex::{Regex}; + +#[derive(Default)] +pub struct FoundPod { + pub name: String, + pub namespace: String, + pub deployment: String +} + +pub fn find_matching_deployment(matcher: &str, namespace: &str) -> Result { + + let deployment_output = { + Command::new("kubectl") + .arg("get") + .arg("deployments") + .arg("-n") + .arg(&namespace) + .output() + .wrap_err("Could not get deployments") + }?; + + let deployments = String::from_utf8(deployment_output.stdout)?.to_string(); + + // Strip numbers and dashes from the matcher + let sanitised_matcher = Regex::new(r"\-+[0-9]+")?.replace_all(matcher, ""); + + let re = Regex::new(&format!(r"[A-Za-z-]*{sanitised_matcher}[A-Za-z-]* "))?; + + let deployment_matches = re.captures(&deployments); + + match deployment_matches { + Some(matches) => { + let deployment: String = matches[0].to_string().replace(" ", ""); + + Ok(deployment) + }, + None => { + Err(eyre!(format!("Failed to find deployment for given pod {} in namespace {}", &sanitised_matcher, &namespace))) + }, + } +} + + +pub fn find_matching_pod(matcher: &str) -> Result { + let output = { + Command::new("kubectl") + .arg("get") + .arg("pods") + .arg("--all-namespaces") + .output() + .wrap_err("Could not execute kubectl get pods") + }?; + + let pods = String::from_utf8(output.stdout).unwrap().to_string(); + + let re = Regex::new(&format!(r"(\b.*\b)( .*{matcher}.*-[0-9A-Za-z-]+)")).unwrap(); + + match re.captures(&*pods) { + Some(matches) => { + let pod: String = matches[2].replace(" ", ""); + let ns: String = matches[1].to_string(); + let deployment: String = find_matching_deployment(&matcher, &ns)?; + + let found_pod : FoundPod = FoundPod { + name: pod, + namespace: ns, + deployment: deployment + }; + + Ok(found_pod) + }, + None => Err(eyre!(format!("Failed to find pod for given matcher {}", &matcher))), + } +} + +pub fn debug_pod(pod: &FoundPod) -> anyhow::Result<()> { + // Get image name. + let image_name = String::from_utf8(Command::new("kubectl") + .arg("get") + .arg("pod") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .arg("-o=jsonpath={.spec.containers[0].image}") + .output() + .unwrap() + .stdout).unwrap().replace("[ ", ""); + + let container_name = String::from_utf8(Command::new("kubectl") + .arg("get") + .arg("pod") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .arg("-o=jsonpath={.spec.containers[0].name}") + .output() + .unwrap() + .stdout).unwrap().replace("[ ", ""); + + + print!("{}", &image_name); + + let _output = { + Command::new("kubectl") + .arg("debug") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .arg("-it") + .arg(format!("--image={}", &image_name)) + .arg(format!("--target={}", &container_name)) + .arg("--") + .arg("sh") + .spawn() + .unwrap() + .wait() + .expect("failed to execute process") + }; + + Ok(()) +} + +pub fn exec_into_pod(pod: &FoundPod) -> anyhow::Result<()> { + let _output = { + Command::new("kubectl") + .arg("exec") + .arg("--stdin") + .arg("--tty") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .arg("--") + .arg("/bin/sh") + .spawn() + .unwrap() + .wait() + .expect("failed to execute process") + }; + + Ok(()) +} + +pub fn delete_pod(pod: &FoundPod) -> anyhow::Result { + let output = { + Command::new("kubectl") + .arg("delete") + .arg("pod") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .arg("--wait=false") + .output() + .expect("failed to execute process") + }; + + let delete = String::from_utf8(output.stdout).unwrap().to_string(); + + Ok(delete) +} + +pub fn get_pods(pod: &FoundPod) -> anyhow::Result { + let output = { + Command::new("kubectl") + .arg("get") + .arg("pods") + .arg("-n") + .arg(&pod.namespace) + .arg("--sort-by=.status.startTime") + .arg("--no-headers") + .stdout(Stdio::piped()) + .spawn() + .unwrap() + }; + + let tac = { + Command::new("tac") + .stdin(Stdio::from(output.stdout.unwrap())) + .output() + .expect("failed to execute process") + }; + + let pods = String::from_utf8(tac.stdout).unwrap(). + replace("Running", "πŸƒ Running"). + replace("Error", "❌ Error"). + replace("Completed", "βœ… Completed"). + replace("Terminating", "πŸ’€οΈ Terminating"). + replace("CrashLoopBackOff", "πŸ”₯ CrashLoopBackOff"). + replace("ImagePullBackOff", "πŸ‘» ImagePullBackOff"). + replace("ContainerCreating", "✨️ ContainerCreating") + .to_string(); + + Ok(pods) +} + +pub fn get_all(pod: &FoundPod) -> anyhow::Result { + let output = { + Command::new("kubectl") + .arg("get") + .arg("all") + .arg("-n") + .arg(&pod.namespace) + .arg("--no-headers") + .output() + }; + + let all = String::from_utf8(output.unwrap().stdout).unwrap().to_string(); + + Ok(all) +} + +pub fn edit_deployment(pod: &FoundPod) -> anyhow::Result<()> { + Command::new("kubectl") + .arg("edit") + .arg("deployment") + .arg(&pod.deployment) + .arg("-n") + .arg(&pod.namespace) + .spawn() + .unwrap() + .wait() + .expect("failed to execute process"); + + Ok(()) +} + +pub fn get_pod_logs(pod: &FoundPod, lite: bool, last_container: bool) -> anyhow::Result { + let output = { + Command::new("kubectl") + .arg("logs") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .arg(if lite {"--tail=500"} else {"--tail=-1"}) + .arg(if last_container {"--previous=true"} else {"--previous=false"}) + .output() + .expect("failed to execute process") + }; + + let logs = String::from_utf8(output.stdout).unwrap().to_string(); + + Ok(logs) +} + +pub fn describe_pod(pod: &FoundPod) -> anyhow::Result { + let output = { + Command::new("kubectl") + .arg("describe") + .arg("pod") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .output() + .expect("failed to execute process") + }; + + let describe = String::from_utf8(output.stdout).unwrap().to_string(); + + Ok(describe) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index aaf2651..2b45bf6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,9 @@ -mod klog; +mod kubectl; +mod cli; +mod gui; -use anyhow::{Result}; -use clap::Parser; -use std::process::{Command}; -use regex::{Captures, Regex}; -use klog::klog; +use color_eyre::{config::HookBuilder, eyre::Result}; +use clap::{command, Parser}; /// Program to execute kubectl commands on resources, using regex matching. #[derive(Parser, Debug)] @@ -14,68 +13,16 @@ struct Args { matcher: String } -#[derive(Default)] -struct FoundPod { - name: String, - namespace: String, - deployment: String -} - fn main() -> Result<()> { - let args = Args::parse(); - - let pod = find_matching_pod(&args.matcher).unwrap(); - klog(pod).unwrap(); - - Ok(()) -} - -fn find_matching_pod(matcher: &str) -> Result { - let output = { - Command::new("kubectl") - .arg("get") - .arg("pods") - .arg("--all-namespaces") - .output() - .expect("failed to execute process") - }; - - let pods = String::from_utf8(output.stdout).unwrap().to_string(); - - let mut re = Regex::new(&format!(r"(\b.*\b)( .*{matcher}.*-[0-9A-Za-z-]+)")).unwrap(); + HookBuilder::default() + .display_env_section(false) // remove env advice + .panic_section(false) // remove panic section + .display_location_section(false) // THIS hides file:line info + .install()?; - // First match will be namespace, second will be pod - let Some(matches): Option = re.captures(&*pods) else { todo!() }; - - let pod: String = matches[2].replace(" ", ""); - let ns: String = matches[1].to_string(); - - let deployment_output = { - Command::new("kubectl") - .arg("get") - .arg("deployments") - .arg("-n") - .arg(&ns) - .output() - .expect("failed to execute process") - }; - - let deployments = String::from_utf8(deployment_output.stdout).unwrap().to_string(); - - // Strip numbers and dashes from the matcher - let sanitised_matcher = Regex::new(r"\-+[0-9]+").unwrap().replace_all(matcher, ""); - - re = Regex::new(&format!(r"[A-Za-z-]*{sanitised_matcher}[A-Za-z-]* ")).unwrap(); - - let Some(deployment_matches): Option = re.captures(&*deployments) else { todo!() }; - - let deployment: String = deployment_matches[0].to_string().replace(" ", ""); + let args = Args::parse(); - let found_pod : FoundPod = FoundPod { - name: pod, - namespace: ns, - deployment: deployment - }; + let pod = kubectl::find_matching_pod(&args.matcher)?; - Ok(found_pod) + Ok(gui::gui(pod)?) } From eca1ae6a7b36657b1b25dbf0f57c6657390bc9ca Mon Sep 17 00:00:00 2001 From: jamesgiu Date: Sat, 19 Jul 2025 01:23:26 +1000 Subject: [PATCH 02/10] QK-XX quirky colours --- src/gui.rs | 58 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/src/gui.rs b/src/gui.rs index 7bb3e05..70377f2 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -5,7 +5,7 @@ use crossterm::{event, execute}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; use rand::Rng; use ratatui::backend::{Backend, CrosstermBackend}; -use ratatui::text::Line; +use ratatui::text::{Line, Span}; use ratatui::{Frame, Terminal}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::prelude::Stylize; @@ -16,6 +16,28 @@ use ratatui::widgets::{Block, Clear, Paragraph, Scrollbar, ScrollbarOrientation, use crate::kubectl::{self, FoundPod}; use crate::cli::{self}; +pub fn render_action_text<'a>(text: &'a str, action: Action, last_action: &Option) -> Span<'a> { + if last_action.is_some() && action == last_action.unwrap() { + return format!("{text}").blue(); + } + + format!("{text}").white() +} + +#[derive(PartialEq, Copy, Clone)] +pub enum Action { + FetchLogs, + LastLogs, + VimLogs, + ViewDesc, + Exec, + EditDeployment, + Debug, + Purge, + World, + Switch +} + #[derive(Default)] struct App { pub vertical_scroll_state: ScrollbarState, @@ -27,6 +49,7 @@ struct App { pub input_text: String, pub target_pod: FoundPod, pub emoji: String, + pub last_action: Option, } pub fn gui(target: FoundPod) -> Result<()> { @@ -142,48 +165,59 @@ fn run_app( match key.code { KeyCode::Char('q') => return Ok(()), KeyCode::Char('s') => { - app.new_pod_search_pop_up = true + app.new_pod_search_pop_up = true; + app.last_action = Some(Action::Switch); } KeyCode::Char('f') => { - fetch_new_logs = true + fetch_new_logs = true; + app.last_action = Some(Action::FetchLogs); }, KeyCode::Char('p') => { delete_pod_next_tick = true; + app.last_action = Some(Action::Purge); }, KeyCode::Char('d') => { text = kubectl::describe_pod(&app.target_pod).unwrap(); app.vertical_scroll = 0; + app.last_action = Some(Action::ViewDesc); }, KeyCode::Char('E') => { terminal.clear().unwrap(); kubectl::edit_deployment(&app.target_pod).unwrap(); terminal.clear().unwrap(); + app.last_action = Some(Action::EditDeployment); }, KeyCode::Char('w') => { text = kubectl::get_pods(&app.target_pod).unwrap(); app.vertical_scroll = 0; + app.last_action = Some(Action::World); }, KeyCode::Char('W') => { text = kubectl::get_all(&app.target_pod).unwrap(); app.vertical_scroll = 0; + app.last_action = Some(Action::World); }, KeyCode::Char('e') => { terminal.clear().unwrap(); kubectl::exec_into_pod(&app.target_pod).unwrap(); terminal.clear().unwrap(); + app.last_action = Some(Action::Exec); }, KeyCode::Char('b') => { terminal.clear().unwrap(); kubectl::debug_pod(&app.target_pod).unwrap(); terminal.clear().unwrap(); + app.last_action = Some(Action::Debug); }, KeyCode::Char('v') => { terminal.clear().unwrap(); cli::open_in_vim(&app.target_pod).unwrap(); terminal.clear().unwrap(); + app.last_action = Some(Action::VimLogs); }, KeyCode::Char('l') => { fetch_prev_container_logs = true; + app.last_action = Some(Action::LastLogs); }, KeyCode::Char('j') | KeyCode::Down => { if app.vertical_scroll + 1 < text.lines().count() { @@ -225,8 +259,11 @@ fn ui(f: &mut Frame, app: &mut App, text: &str) { let pod_name = &app.target_pod.name; let pod_deployment = &app.target_pod.deployment; let pod_ns = &app.target_pod.namespace; + let last_action = &app.last_action; - let details_content = "πŸ“œ [f]etch logs πŸ“– [l]ast logs πŸ“ [v]im logs"; + let details_content = vec![render_action_text("πŸ“œ [f]etch logs ", Action::FetchLogs, last_action), + render_action_text("πŸ“– [l]ast logs ", Action::LastLogs, last_action), + render_action_text("πŸ“ [v]im logs", Action::VimLogs, last_action)]; let chunks = Layout::vertical([ Constraint::Min(1), @@ -242,10 +279,17 @@ fn ui(f: &mut Frame, app: &mut App, text: &str) { .block( Block::bordered().white() .title_top(Line::from(format!("{0} {pod_ns}/{pod_deployment}/{pod_name}", app.emoji)).left_aligned().bold().white()) - .title_top(Line::from(format!("πŸ”Ž [d]esc πŸ’» [e]xec ✏️ [E]dit 🐞 de[b]ug πŸ’€ [p]urge [q]uit βœ–οΈ")).right_aligned().white()) + .title_top(Line::from(vec![ + render_action_text("πŸ”Ž [d]esc ", Action::ViewDesc, last_action), + render_action_text("πŸ’» [e]xec ", Action::Exec, last_action), + render_action_text("✏️ [E]dit ", Action::EditDeployment, last_action), + render_action_text("🐞 de[b]ug ", Action::Debug, last_action), + render_action_text("πŸ’€ [p]urge ", Action::Purge, last_action), + render_action_text("[q]uit βœ–οΈ", Action::Purge, last_action)]).right_aligned().white()) .title_bottom(details_content).to_owned() - .title_bottom(Line::from(format!("πŸ—ΊοΈ [W/w]orld [s]witch βš™οΈ").white()).right_aligned()) - ) + .title_bottom(Line::from(vec![ + render_action_text("πŸ—ΊοΈ [W/w]orld ", Action::World, last_action), + render_action_text("[s]witch βš™οΈ", Action::Switch, last_action)]).white().right_aligned())) .style(Style::default().fg(Color::Rgb(186, 186, 186))) .scroll((app.vertical_scroll as u16, app.horizontal_scroll as u16)) .wrap(Wrap { trim: true }); From 129476e23c54378413eb1f94cc140e296e898fe3 Mon Sep 17 00:00:00 2001 From: jamesgiu Date: Sun, 20 Jul 2025 18:12:45 +1000 Subject: [PATCH 03/10] QK-XX highlight per menu action --- src/gui.rs | 52 +++++++++++++++++++++++----------------------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/src/gui.rs b/src/gui.rs index 70377f2..06e3c0b 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -16,7 +16,7 @@ use ratatui::widgets::{Block, Clear, Paragraph, Scrollbar, ScrollbarOrientation, use crate::kubectl::{self, FoundPod}; use crate::cli::{self}; -pub fn render_action_text<'a>(text: &'a str, action: Action, last_action: &Option) -> Span<'a> { +pub fn render_action_text<'a>(text: &'a str, action: InternalAction, last_action: &Option) -> Span<'a> { if last_action.is_some() && action == last_action.unwrap() { return format!("{text}").blue(); } @@ -25,14 +25,10 @@ pub fn render_action_text<'a>(text: &'a str, action: Action, last_action: &Optio } #[derive(PartialEq, Copy, Clone)] -pub enum Action { +pub enum InternalAction { FetchLogs, LastLogs, - VimLogs, ViewDesc, - Exec, - EditDeployment, - Debug, Purge, World, Switch @@ -49,7 +45,7 @@ struct App { pub input_text: String, pub target_pod: FoundPod, pub emoji: String, - pub last_action: Option, + pub last_action: Option, } pub fn gui(target: FoundPod) -> Result<()> { @@ -68,6 +64,7 @@ pub fn gui(target: FoundPod) -> Result<()> { // create app and run it let tick_rate = Duration::from_millis(250); let mut app = App::default(); + app.last_action = Some(InternalAction::FetchLogs); app.target_pod = target; let res = run_app(&mut terminal, app, tick_rate); @@ -153,6 +150,7 @@ fn run_app( app.new_pod_search_pop_up = false; app.target_pod = kubectl::find_matching_pod(app.input_text.as_str()).unwrap(); fetch_new_logs = true; + app.last_action = Some(InternalAction::FetchLogs); app.vertical_scroll = 0; app.input_text.clear(); } @@ -166,58 +164,54 @@ fn run_app( KeyCode::Char('q') => return Ok(()), KeyCode::Char('s') => { app.new_pod_search_pop_up = true; - app.last_action = Some(Action::Switch); + app.last_action = Some(InternalAction::Switch); } KeyCode::Char('f') => { fetch_new_logs = true; - app.last_action = Some(Action::FetchLogs); + app.last_action = Some(InternalAction::FetchLogs); }, KeyCode::Char('p') => { delete_pod_next_tick = true; - app.last_action = Some(Action::Purge); + app.last_action = Some(InternalAction::Purge); }, KeyCode::Char('d') => { text = kubectl::describe_pod(&app.target_pod).unwrap(); app.vertical_scroll = 0; - app.last_action = Some(Action::ViewDesc); + app.last_action = Some(InternalAction::ViewDesc); }, KeyCode::Char('E') => { terminal.clear().unwrap(); kubectl::edit_deployment(&app.target_pod).unwrap(); terminal.clear().unwrap(); - app.last_action = Some(Action::EditDeployment); }, KeyCode::Char('w') => { text = kubectl::get_pods(&app.target_pod).unwrap(); app.vertical_scroll = 0; - app.last_action = Some(Action::World); + app.last_action = Some(InternalAction::World); }, KeyCode::Char('W') => { text = kubectl::get_all(&app.target_pod).unwrap(); app.vertical_scroll = 0; - app.last_action = Some(Action::World); + app.last_action = Some(InternalAction::World); }, KeyCode::Char('e') => { terminal.clear().unwrap(); kubectl::exec_into_pod(&app.target_pod).unwrap(); terminal.clear().unwrap(); - app.last_action = Some(Action::Exec); }, KeyCode::Char('b') => { terminal.clear().unwrap(); kubectl::debug_pod(&app.target_pod).unwrap(); terminal.clear().unwrap(); - app.last_action = Some(Action::Debug); }, KeyCode::Char('v') => { terminal.clear().unwrap(); cli::open_in_vim(&app.target_pod).unwrap(); terminal.clear().unwrap(); - app.last_action = Some(Action::VimLogs); }, KeyCode::Char('l') => { fetch_prev_container_logs = true; - app.last_action = Some(Action::LastLogs); + app.last_action = Some(InternalAction::LastLogs); }, KeyCode::Char('j') | KeyCode::Down => { if app.vertical_scroll + 1 < text.lines().count() { @@ -261,9 +255,9 @@ fn ui(f: &mut Frame, app: &mut App, text: &str) { let pod_ns = &app.target_pod.namespace; let last_action = &app.last_action; - let details_content = vec![render_action_text("πŸ“œ [f]etch logs ", Action::FetchLogs, last_action), - render_action_text("πŸ“– [l]ast logs ", Action::LastLogs, last_action), - render_action_text("πŸ“ [v]im logs", Action::VimLogs, last_action)]; + let details_content = vec![render_action_text("πŸ“œ [f]etch logs ", InternalAction::FetchLogs, last_action), + render_action_text("πŸ“– [l]ast logs ", InternalAction::LastLogs, last_action), + Span::from("πŸ“ [v]im logs")]; let chunks = Layout::vertical([ Constraint::Min(1), @@ -280,16 +274,16 @@ fn ui(f: &mut Frame, app: &mut App, text: &str) { Block::bordered().white() .title_top(Line::from(format!("{0} {pod_ns}/{pod_deployment}/{pod_name}", app.emoji)).left_aligned().bold().white()) .title_top(Line::from(vec![ - render_action_text("πŸ”Ž [d]esc ", Action::ViewDesc, last_action), - render_action_text("πŸ’» [e]xec ", Action::Exec, last_action), - render_action_text("✏️ [E]dit ", Action::EditDeployment, last_action), - render_action_text("🐞 de[b]ug ", Action::Debug, last_action), - render_action_text("πŸ’€ [p]urge ", Action::Purge, last_action), - render_action_text("[q]uit βœ–οΈ", Action::Purge, last_action)]).right_aligned().white()) + render_action_text("πŸ”Ž [d]esc ", InternalAction::ViewDesc, last_action), + Span::from("πŸ’» [e]xec "), + Span::from("✏️ [E]dit "), + Span::from("🐞 de[b]ug "), + render_action_text("πŸ’€ [p]urge ", InternalAction::Purge, last_action), + Span::from("[q]uit βœ–οΈ")]).right_aligned().white()) .title_bottom(details_content).to_owned() .title_bottom(Line::from(vec![ - render_action_text("πŸ—ΊοΈ [W/w]orld ", Action::World, last_action), - render_action_text("[s]witch βš™οΈ", Action::Switch, last_action)]).white().right_aligned())) + render_action_text("πŸ—ΊοΈ [W/w]orld ", InternalAction::World, last_action), + render_action_text("[s]witch βš™οΈ", InternalAction::Switch, last_action)]).white().right_aligned())) .style(Style::default().fg(Color::Rgb(186, 186, 186))) .scroll((app.vertical_scroll as u16, app.horizontal_scroll as u16)) .wrap(Wrap { trim: true }); From a1574860dcae05dd78d547707204c7ea77dadb11 Mon Sep 17 00:00:00 2001 From: jamesgiu Date: Sun, 20 Jul 2025 18:31:38 +1000 Subject: [PATCH 04/10] QK-XX Add error --- src/gui.rs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/gui.rs b/src/gui.rs index 06e3c0b..fe2847e 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -41,6 +41,7 @@ struct App { pub vertical_scroll: usize, pub horizontal_scroll: usize, pub show_pod_deleted_pop_up: bool, + pub show_switch_error_text: bool, pub new_pod_search_pop_up: bool, pub input_text: String, pub target_pod: FoundPod, @@ -147,12 +148,23 @@ fn run_app( app.input_text.clear(); } KeyCode::Enter => { - app.new_pod_search_pop_up = false; - app.target_pod = kubectl::find_matching_pod(app.input_text.as_str()).unwrap(); - fetch_new_logs = true; - app.last_action = Some(InternalAction::FetchLogs); - app.vertical_scroll = 0; - app.input_text.clear(); + let matching_pod_result = kubectl::find_matching_pod(app.input_text.as_str()); + match matching_pod_result { + Ok(matching_pod) => { + app.target_pod = matching_pod; + fetch_new_logs = true; + app.last_action = Some(InternalAction::FetchLogs); + app.vertical_scroll = 0; + app.input_text.clear(); + app.show_switch_error_text = false; + app.new_pod_search_pop_up = false; + }, + Err(_) => { + app.input_text.clear(); + app.show_switch_error_text = true; + } + } + } KeyCode::Backspace => { app.input_text.pop(); @@ -306,7 +318,10 @@ fn ui(f: &mut Frame, app: &mut App, text: &str) { } if app.new_pod_search_pop_up { - let block = Block::bordered().title("πŸ”Ž Enter new pod matcher (ESC to close)").on_black(); + let mut block = Block::bordered().title("πŸ”Ž Enter new pod matcher (ESC to close)").on_black(); + if app.show_switch_error_text { + block = Block::bordered().title("❌ Pod not found! Please search again.").on_red(); + } let area = centered_rect(60, 20, f.size()); let input = Paragraph::new(app.input_text.as_str().white()) From 7b49ba05dc04c514272adb209813e00c4d14e9ce Mon Sep 17 00:00:00 2001 From: jamesgiu Date: Sun, 27 Jul 2025 15:17:44 +1000 Subject: [PATCH 05/10] QK-XX Add error handling and thiserror --- .github/workflows/rust.yml | 2 - Cargo.toml | 2 +- src/cli.rs | 4 +- src/gui.rs | 9 +- src/kubectl.rs | 392 +++++++++++++++++++------------------ 5 files changed, 206 insertions(+), 203 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9fd45e0..813178c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -11,9 +11,7 @@ env: jobs: build: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - name: Build diff --git a/Cargo.toml b/Cargo.toml index 3ea2eeb..c0694c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,6 @@ regex = "1.8.4" config = "0.14.0" strum = "0.26.2" color-eyre = "0.6.3" -anyhow = "1.0.82" clap = { version = "4.5.4", features = ["derive"] } rand = "0.9.0" +thiserror = "2.0.12" diff --git a/src/cli.rs b/src/cli.rs index f1a1dc8..1246d1c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,8 +1,8 @@ use std::{fs, process::Command}; - +use color_eyre::{Result}; use crate::kubectl::{self, FoundPod}; -pub fn open_in_vim(pod: &FoundPod) -> anyhow::Result<()> { +pub fn open_in_vim(pod: &FoundPod) -> Result<()> { let logs = kubectl::get_pod_logs(pod, false, false).unwrap(); let name = &pod.name; let fname = format!("/tmp/klog_{name}"); diff --git a/src/gui.rs b/src/gui.rs index fe2847e..88d1e96 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -17,9 +17,11 @@ use crate::kubectl::{self, FoundPod}; use crate::cli::{self}; pub fn render_action_text<'a>(text: &'a str, action: InternalAction, last_action: &Option) -> Span<'a> { - if last_action.is_some() && action == last_action.unwrap() { - return format!("{text}").blue(); - } + if let Some(last_action) = last_action { + if *last_action == action { + return format!("{text}").blue(); + } + } format!("{text}").white() } @@ -164,7 +166,6 @@ fn run_app( app.show_switch_error_text = true; } } - } KeyCode::Backspace => { app.input_text.pop(); diff --git a/src/kubectl.rs b/src/kubectl.rs index 5ef5663..0cde13f 100644 --- a/src/kubectl.rs +++ b/src/kubectl.rs @@ -1,262 +1,266 @@ use std::process::{Command, Stdio}; -use color_eyre::eyre::{eyre, Context, Result}; -use regex::{Regex}; +use color_eyre::eyre::{Context, Result}; +use regex::Regex; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum KubeError { + #[error("Resource not found with provided matcher: {0} in namespace {1}")] + ResourceNotFoundError(String, String), +} #[derive(Default)] pub struct FoundPod { pub name: String, pub namespace: String, - pub deployment: String + pub deployment: String, } pub fn find_matching_deployment(matcher: &str, namespace: &str) -> Result { + let deployment_output = Command::new("kubectl") + .arg("get") + .arg("deployments") + .arg("-n") + .arg(&namespace) + .output() + .wrap_err("Could not get deployments")?; - let deployment_output = { - Command::new("kubectl") - .arg("get") - .arg("deployments") - .arg("-n") - .arg(&namespace) - .output() - .wrap_err("Could not get deployments") - }?; - - let deployments = String::from_utf8(deployment_output.stdout)?.to_string(); + let deployments = String::from_utf8(deployment_output.stdout)?; - // Strip numbers and dashes from the matcher - let sanitised_matcher = Regex::new(r"\-+[0-9]+")?.replace_all(matcher, ""); + let sanitised_matcher = Regex::new(r"\-+[0-9]+")? + .replace_all(matcher, "") + .to_string(); let re = Regex::new(&format!(r"[A-Za-z-]*{sanitised_matcher}[A-Za-z-]* "))?; - let deployment_matches = re.captures(&deployments); - - match deployment_matches { + match re.captures(&deployments) { Some(matches) => { let deployment: String = matches[0].to_string().replace(" ", ""); - Ok(deployment) - }, - None => { - Err(eyre!(format!("Failed to find deployment for given pod {} in namespace {}", &sanitised_matcher, &namespace))) - }, + } + None => Err(KubeError::ResourceNotFoundError( + sanitised_matcher, + namespace.to_string(), + ) + .into()), } } - pub fn find_matching_pod(matcher: &str) -> Result { - let output = { - Command::new("kubectl") - .arg("get") - .arg("pods") - .arg("--all-namespaces") - .output() - .wrap_err("Could not execute kubectl get pods") - }?; + let output = Command::new("kubectl") + .arg("get") + .arg("pods") + .arg("--all-namespaces") + .output() + .wrap_err("Could not execute kubectl get pods")?; - let pods = String::from_utf8(output.stdout).unwrap().to_string(); + let pods = String::from_utf8(output.stdout)?; - let re = Regex::new(&format!(r"(\b.*\b)( .*{matcher}.*-[0-9A-Za-z-]+)")).unwrap(); + let re = Regex::new(&format!( + r"(\b.*\b)( .*{matcher}.*-[0-9A-Za-z-]+)" + ))?; - match re.captures(&*pods) { + match re.captures(&pods) { Some(matches) => { - let pod: String = matches[2].replace(" ", ""); - let ns: String = matches[1].to_string(); - let deployment: String = find_matching_deployment(&matcher, &ns)?; - - let found_pod : FoundPod = FoundPod { + let pod = matches + .get(2) + .ok_or_else(|| color_eyre::eyre::eyre!("No pod name match found"))? + .as_str() + .replace(" ", ""); + let ns = matches + .get(1) + .ok_or_else(|| color_eyre::eyre::eyre!("No namespace match found"))? + .as_str() + .to_string(); + let deployment = find_matching_deployment(&matcher, &ns)?; + + Ok(FoundPod { name: pod, namespace: ns, - deployment: deployment - }; - - Ok(found_pod) - }, - None => Err(eyre!(format!("Failed to find pod for given matcher {}", &matcher))), + deployment, + }) + } + None => Err(KubeError::ResourceNotFoundError( + matcher.to_string(), + "all".to_string(), + ) + .into()), } } -pub fn debug_pod(pod: &FoundPod) -> anyhow::Result<()> { - // Get image name. - let image_name = String::from_utf8(Command::new("kubectl") - .arg("get") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("-o=jsonpath={.spec.containers[0].image}") - .output() - .unwrap() - .stdout).unwrap().replace("[ ", ""); - - let container_name = String::from_utf8(Command::new("kubectl") - .arg("get") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("-o=jsonpath={.spec.containers[0].name}") - .output() - .unwrap() - .stdout).unwrap().replace("[ ", ""); - - - print!("{}", &image_name); - - let _output = { +pub fn debug_pod(pod: &FoundPod) -> Result<()> { + let image_name = String::from_utf8( Command::new("kubectl") - .arg("debug") + .arg("get") + .arg("pod") .arg(&pod.name) .arg("-n") .arg(&pod.namespace) - .arg("-it") - .arg(format!("--image={}", &image_name)) - .arg(format!("--target={}", &container_name)) - .arg("--") - .arg("sh") - .spawn() - .unwrap() - .wait() - .expect("failed to execute process") - }; - - Ok(()) -} + .arg("-o=jsonpath={.spec.containers[0].image}") + .output() + .wrap_err("Failed to get image name")? + .stdout, + ) + .wrap_err("Invalid UTF-8 in image name")? + .replace("[ ", ""); -pub fn exec_into_pod(pod: &FoundPod) -> anyhow::Result<()> { - let _output = { + let container_name = String::from_utf8( Command::new("kubectl") - .arg("exec") - .arg("--stdin") - .arg("--tty") + .arg("get") + .arg("pod") .arg(&pod.name) .arg("-n") .arg(&pod.namespace) - .arg("--") - .arg("/bin/sh") - .spawn() - .unwrap() - .wait() - .expect("failed to execute process") - }; + .arg("-o=jsonpath={.spec.containers[0].name}") + .output() + .wrap_err("Failed to get container name")? + .stdout, + ) + .wrap_err("Invalid UTF-8 in container name")? + .replace("[ ", ""); + + Command::new("kubectl") + .arg("debug") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .arg("-it") + .arg(format!("--image={}", &image_name)) + .arg(format!("--target={}", &container_name)) + .arg("--") + .arg("sh") + .spawn() + .wrap_err("Failed to spawn kubectl debug")? + .wait() + .wrap_err("Failed to wait for kubectl debug")?; Ok(()) } -pub fn delete_pod(pod: &FoundPod) -> anyhow::Result { - let output = { - Command::new("kubectl") - .arg("delete") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("--wait=false") - .output() - .expect("failed to execute process") - }; +pub fn exec_into_pod(pod: &FoundPod) -> Result<()> { + Command::new("kubectl") + .arg("exec") + .arg("--stdin") + .arg("--tty") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .arg("--") + .arg("/bin/sh") + .spawn() + .wrap_err("Failed to exec into pod")? + .wait() + .wrap_err("Failed to wait for exec command")?; + + Ok(()) +} - let delete = String::from_utf8(output.stdout).unwrap().to_string(); +pub fn delete_pod(pod: &FoundPod) -> Result { + let output = Command::new("kubectl") + .arg("delete") + .arg("pod") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .arg("--wait=false") + .output() + .wrap_err("Failed to delete pod")?; + let delete = String::from_utf8(output.stdout)?; Ok(delete) } -pub fn get_pods(pod: &FoundPod) -> anyhow::Result { - let output = { - Command::new("kubectl") - .arg("get") - .arg("pods") - .arg("-n") - .arg(&pod.namespace) - .arg("--sort-by=.status.startTime") - .arg("--no-headers") - .stdout(Stdio::piped()) - .spawn() - .unwrap() - }; - - let tac = { - Command::new("tac") - .stdin(Stdio::from(output.stdout.unwrap())) - .output() - .expect("failed to execute process") - }; - - let pods = String::from_utf8(tac.stdout).unwrap(). - replace("Running", "πŸƒ Running"). - replace("Error", "❌ Error"). - replace("Completed", "βœ… Completed"). - replace("Terminating", "πŸ’€οΈ Terminating"). - replace("CrashLoopBackOff", "πŸ”₯ CrashLoopBackOff"). - replace("ImagePullBackOff", "πŸ‘» ImagePullBackOff"). - replace("ContainerCreating", "✨️ ContainerCreating") - .to_string(); +pub fn get_pods(pod: &FoundPod) -> Result { + let output = Command::new("kubectl") + .arg("get") + .arg("pods") + .arg("-n") + .arg(&pod.namespace) + .arg("--sort-by=.status.startTime") + .arg("--no-headers") + .stdout(Stdio::piped()) + .spawn() + .wrap_err("Failed to run kubectl get pods")?; + + let tac = Command::new("tac") + .stdin(output.stdout.ok_or_else(|| { + color_eyre::eyre::eyre!("Failed to capture stdout from kubectl get pods") + })?) + .output() + .wrap_err("Failed to run tac on pods output")?; + + let pods = String::from_utf8(tac.stdout) + .wrap_err("Invalid UTF-8 in pods output")? + .replace("Running", "πŸƒ Running") + .replace("Error", "❌ Error") + .replace("Completed", "βœ… Completed") + .replace("Terminating", "πŸ’€οΈ Terminating") + .replace("CrashLoopBackOff", "πŸ”₯ CrashLoopBackOff") + .replace("ImagePullBackOff", "πŸ‘» ImagePullBackOff") + .replace("ContainerCreating", "✨️ ContainerCreating"); Ok(pods) } -pub fn get_all(pod: &FoundPod) -> anyhow::Result { - let output = { - Command::new("kubectl") - .arg("get") - .arg("all") - .arg("-n") - .arg(&pod.namespace) - .arg("--no-headers") - .output() - }; - - let all = String::from_utf8(output.unwrap().stdout).unwrap().to_string(); +pub fn get_all(pod: &FoundPod) -> Result { + let output = Command::new("kubectl") + .arg("get") + .arg("all") + .arg("-n") + .arg(&pod.namespace) + .arg("--no-headers") + .output() + .wrap_err("Failed to get all resources")?; + let all = String::from_utf8(output.stdout)?; Ok(all) } -pub fn edit_deployment(pod: &FoundPod) -> anyhow::Result<()> { +pub fn edit_deployment(pod: &FoundPod) -> Result<()> { Command::new("kubectl") - .arg("edit") - .arg("deployment") - .arg(&pod.deployment) - .arg("-n") - .arg(&pod.namespace) - .spawn() - .unwrap() - .wait() - .expect("failed to execute process"); + .arg("edit") + .arg("deployment") + .arg(&pod.deployment) + .arg("-n") + .arg(&pod.namespace) + .spawn() + .wrap_err("Failed to spawn kubectl edit")? + .wait() + .wrap_err("Failed to wait for edit process")?; Ok(()) } -pub fn get_pod_logs(pod: &FoundPod, lite: bool, last_container: bool) -> anyhow::Result { - let output = { - Command::new("kubectl") - .arg("logs") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg(if lite {"--tail=500"} else {"--tail=-1"}) - .arg(if last_container {"--previous=true"} else {"--previous=false"}) - .output() - .expect("failed to execute process") - }; - - let logs = String::from_utf8(output.stdout).unwrap().to_string(); +pub fn get_pod_logs(pod: &FoundPod, lite: bool, last_container: bool) -> Result { + let output = Command::new("kubectl") + .arg("logs") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .arg(if lite { "--tail=500" } else { "--tail=-1" }) + .arg(if last_container { + "--previous=true" + } else { + "--previous=false" + }) + .output() + .wrap_err("Failed to get pod logs")?; + let logs = String::from_utf8(output.stdout)?; Ok(logs) } -pub fn describe_pod(pod: &FoundPod) -> anyhow::Result { - let output = { - Command::new("kubectl") - .arg("describe") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .output() - .expect("failed to execute process") - }; - - let describe = String::from_utf8(output.stdout).unwrap().to_string(); +pub fn describe_pod(pod: &FoundPod) -> Result { + let output = Command::new("kubectl") + .arg("describe") + .arg("pod") + .arg(&pod.name) + .arg("-n") + .arg(&pod.namespace) + .output() + .wrap_err("Failed to describe pod")?; + let describe = String::from_utf8(output.stdout)?; Ok(describe) -} \ No newline at end of file +} From ecd22b6412ede1b3d1fdbed5cced0ee2659e83e3 Mon Sep 17 00:00:00 2001 From: jamesgiu Date: Sun, 27 Jul 2025 22:27:41 +1000 Subject: [PATCH 06/10] QK-XX first test! --- src/gui.rs | 5 +- src/kubectl.rs | 192 ++++++++++++++++++++++++++++++++++++++++++------- src/main.rs | 4 +- 3 files changed, 171 insertions(+), 30 deletions(-) diff --git a/src/gui.rs b/src/gui.rs index 88d1e96..6ffc1d0 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -13,7 +13,7 @@ use ratatui::style::{Color, Style}; use color_eyre::eyre::{Result}; use ratatui::widgets::{Block, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}; -use crate::kubectl::{self, FoundPod}; +use crate::kubectl::{self, FoundPod, KubectlRunnerAgent}; use crate::cli::{self}; pub fn render_action_text<'a>(text: &'a str, action: InternalAction, last_action: &Option) -> Span<'a> { @@ -97,6 +97,7 @@ fn run_app( let mut fetch_prev_container_logs = false; let mut delete_pod_next_tick = false; let mut reset_scroll = true; + let runner = KubectlRunnerAgent; let mut text = kubectl::get_pod_logs(&app.target_pod, true, false).unwrap(); let icons = ["🐝", "πŸ¦€", "πŸ‹", "🐧", "πŸ¦•", "🦐", "🐬", "🦞", "πŸ€–", "🐀", "πŸͺΏ"]; // Create a random number generator @@ -150,7 +151,7 @@ fn run_app( app.input_text.clear(); } KeyCode::Enter => { - let matching_pod_result = kubectl::find_matching_pod(app.input_text.as_str()); + let matching_pod_result = kubectl::find_matching_pod(&runner, app.input_text.as_str()); match matching_pod_result { Ok(matching_pod) => { app.target_pod = matching_pod; diff --git a/src/kubectl.rs b/src/kubectl.rs index 0cde13f..c5a7734 100644 --- a/src/kubectl.rs +++ b/src/kubectl.rs @@ -4,29 +4,60 @@ use color_eyre::eyre::{Context, Result}; use regex::Regex; use thiserror::Error; +// TODO +// Refactor to use KubectlRunner +// Mock KubectlRunner for tests +// More RustDoc + +pub trait KubectlRunner { + fn run_commands(&self, args: &[&str]) -> Result; +} + +pub struct KubectlRunnerAgent; + +impl KubectlRunner for KubectlRunnerAgent { + fn run_commands(&self, args: &[&str]) -> Result { + let output = String::from_utf8(Command::new("kubectl") + .args(args) + .output() + .wrap_err("Could not run commands")?.stdout)?; + + Ok(output) + } +} + +/// Custom error type for Kubernetes resource matching operations. #[derive(Error, Debug)] pub enum KubeError { + /// Raised when a resource could not be found for a given matcher in a specified namespace. #[error("Resource not found with provided matcher: {0} in namespace {1}")] ResourceNotFoundError(String, String), } +/// Represents a Kubernetes pod and its associated metadata. #[derive(Default)] pub struct FoundPod { + /// Name of the pod. pub name: String, + /// Namespace where the pod is located. pub namespace: String, + /// Name of the deployment managing the pod. pub deployment: String, } -pub fn find_matching_deployment(matcher: &str, namespace: &str) -> Result { - let deployment_output = Command::new("kubectl") - .arg("get") - .arg("deployments") - .arg("-n") - .arg(&namespace) - .output() - .wrap_err("Could not get deployments")?; - - let deployments = String::from_utf8(deployment_output.stdout)?; +/// Attempts to find a matching Kubernetes deployment based on a matcher string and namespace. +/// +/// This function uses `kubectl get deployments` and regex matching to find a relevant deployment. +/// +/// # Arguments +/// * `matcher` - A string to match the deployment name. +/// +/// * `namespace` - The Kubernetes namespace to search in. +/// +/// # Errors +/// Returns an error if `kubectl` fails, the output is invalid UTF-8, or no deployment is found. +pub fn find_matching_deployment(runner: &dyn KubectlRunner, matcher: &str, namespace: &str) -> Result { + let deployments = runner.run_commands(&["get", "deployments", "-n", namespace])?; let sanitised_matcher = Regex::new(r"\-+[0-9]+")? .replace_all(matcher, "") @@ -47,16 +78,19 @@ pub fn find_matching_deployment(matcher: &str, namespace: &str) -> Result Result { - let output = Command::new("kubectl") - .arg("get") - .arg("pods") - .arg("--all-namespaces") - .output() - .wrap_err("Could not execute kubectl get pods")?; - - let pods = String::from_utf8(output.stdout)?; - +/// Finds a pod by using a matcher string across all namespaces. +/// +/// # Arguments +/// * `matcher` - A string used to locate a matching pod. +/// +/// # Returns +/// A `FoundPod` struct containing the pod name, namespace, and owning deployment. +/// +/// # Errors +/// Returns an error if the pod or its deployment cannot be found. +pub fn find_matching_pod(runner: &dyn KubectlRunner, matcher: &str) -> Result { + let pods = runner.run_commands(&["get", "pods", "--all-namespaces"])?; + let re = Regex::new(&format!( r"(\b.*\b)( .*{matcher}.*-[0-9A-Za-z-]+)" ))?; @@ -73,7 +107,7 @@ pub fn find_matching_pod(matcher: &str) -> Result { .ok_or_else(|| color_eyre::eyre::eyre!("No namespace match found"))? .as_str() .to_string(); - let deployment = find_matching_deployment(&matcher, &ns)?; + let deployment = find_matching_deployment(runner, &matcher, &ns)?; Ok(FoundPod { name: pod, @@ -89,6 +123,13 @@ pub fn find_matching_pod(matcher: &str) -> Result { } } +/// Spawns a debug container into the given pod using the same image and container name. +/// +/// # Arguments +/// * `pod` - A reference to the `FoundPod` struct representing the target pod. +/// +/// # Errors +/// Returns an error if `kubectl debug` or the underlying metadata fetch commands fail. pub fn debug_pod(pod: &FoundPod) -> Result<()> { let image_name = String::from_utf8( Command::new("kubectl") @@ -101,8 +142,7 @@ pub fn debug_pod(pod: &FoundPod) -> Result<()> { .output() .wrap_err("Failed to get image name")? .stdout, - ) - .wrap_err("Invalid UTF-8 in image name")? + )? .replace("[ ", ""); let container_name = String::from_utf8( @@ -116,8 +156,7 @@ pub fn debug_pod(pod: &FoundPod) -> Result<()> { .output() .wrap_err("Failed to get container name")? .stdout, - ) - .wrap_err("Invalid UTF-8 in container name")? + )? .replace("[ ", ""); Command::new("kubectl") @@ -138,6 +177,13 @@ pub fn debug_pod(pod: &FoundPod) -> Result<()> { Ok(()) } +/// Starts an interactive shell session inside a running pod container. +/// +/// # Arguments +/// * `pod` - A reference to the target `FoundPod`. +/// +/// # Errors +/// Returns an error if the `kubectl exec` command fails. pub fn exec_into_pod(pod: &FoundPod) -> Result<()> { Command::new("kubectl") .arg("exec") @@ -156,6 +202,16 @@ pub fn exec_into_pod(pod: &FoundPod) -> Result<()> { Ok(()) } +/// Deletes the given pod without waiting for completion. +/// +/// # Arguments +/// * `pod` - A reference to the pod to delete. +/// +/// # Returns +/// A string output of the `kubectl delete` command. +/// +/// # Errors +/// Returns an error if the command fails or the output can't be decoded. pub fn delete_pod(pod: &FoundPod) -> Result { let output = Command::new("kubectl") .arg("delete") @@ -171,6 +227,16 @@ pub fn delete_pod(pod: &FoundPod) -> Result { Ok(delete) } +/// Retrieves a reversed and formatted list of pods sorted by start time in the given namespace. +/// +/// # Arguments +/// * `pod` - A reference to the namespace's pod (only namespace field is used). +/// +/// # Returns +/// A formatted string of pod statuses. +/// +/// # Errors +/// Returns an error if the `kubectl` or `tac` commands fail or output can't be parsed. pub fn get_pods(pod: &FoundPod) -> Result { let output = Command::new("kubectl") .arg("get") @@ -190,8 +256,7 @@ pub fn get_pods(pod: &FoundPod) -> Result { .output() .wrap_err("Failed to run tac on pods output")?; - let pods = String::from_utf8(tac.stdout) - .wrap_err("Invalid UTF-8 in pods output")? + let pods = String::from_utf8(tac.stdout)? .replace("Running", "πŸƒ Running") .replace("Error", "❌ Error") .replace("Completed", "βœ… Completed") @@ -203,6 +268,16 @@ pub fn get_pods(pod: &FoundPod) -> Result { Ok(pods) } +/// Lists all resources in the pod's namespace (no headers). +/// +/// # Arguments +/// * `pod` - A reference to the pod (only namespace is used). +/// +/// # Returns +/// Output of `kubectl get all`. +/// +/// # Errors +/// Returns an error if the command fails or output is invalid. pub fn get_all(pod: &FoundPod) -> Result { let output = Command::new("kubectl") .arg("get") @@ -217,6 +292,13 @@ pub fn get_all(pod: &FoundPod) -> Result { Ok(all) } +/// Opens the deployment of the given pod in an editor. +/// +/// # Arguments +/// * `pod` - The pod whose deployment should be edited. +/// +/// # Errors +/// Returns an error if `kubectl edit` fails to spawn or complete. pub fn edit_deployment(pod: &FoundPod) -> Result<()> { Command::new("kubectl") .arg("edit") @@ -232,6 +314,26 @@ pub fn edit_deployment(pod: &FoundPod) -> Result<()> { Ok(()) } +/// Fetches logs from a given pod, optionally from the last container or limiting output. +/// +/// # Arguments +/// * `pod` - The pod to retrieve logs from. +/// * `lite` - If `true`, limits to last 500 lines. +/// * `last_container` - If `true`, fetches logs from the previous container instance. +/// +/// # Returns +/// The logs as a string. +/// +/// # Errors +/// Returns an error if the command fails or output is not UTF-8. +/// +/// # Example +/// ```no_run +/// let pod = find_matching_pod("api")?; +/// let logs = get_pod_logs(&pod, true, false)?; +/// println!("{}", logs); +/// # Ok::<(), color_eyre::eyre::Report>(()) +/// ``` pub fn get_pod_logs(pod: &FoundPod, lite: bool, last_container: bool) -> Result { let output = Command::new("kubectl") .arg("logs") @@ -251,6 +353,16 @@ pub fn get_pod_logs(pod: &FoundPod, lite: bool, last_container: bool) -> Result< Ok(logs) } +/// Describes the given pod using `kubectl describe`. +/// +/// # Arguments +/// * `pod` - The pod to describe. +/// +/// # Returns +/// The full description string. +/// +/// # Errors +/// Returns an error if the command fails or the output can't be decoded. pub fn describe_pod(pod: &FoundPod) -> Result { let output = Command::new("kubectl") .arg("describe") @@ -264,3 +376,29 @@ pub fn describe_pod(pod: &FoundPod) -> Result { let describe = String::from_utf8(output.stdout)?; Ok(describe) } + +#[cfg(test)] +mod tests { + use crate::kubectl::{find_matching_deployment, KubectlRunner}; + + pub struct TestKubeCtlRunner<'a> { + expected_args: &'a [&'a str] + } + + #[test] + fn test_find_matching_deployment_succeess() { + let matcher = "hello"; + let namespace = "namespace"; + impl KubectlRunner for TestKubeCtlRunner<'_> { + fn run_commands(&self, args: &[&str]) -> color_eyre::eyre::Result { + // Ensure args are as expected. + assert_eq!(args, self.expected_args); + Ok(String::from( + "NAME READY UP-TO-DATE AVAILABLE AGE\n".to_owned() + + "ahoy-hello-world 2/2 2 2 266d")) + } + } + let matched_result = find_matching_deployment(&TestKubeCtlRunner { expected_args: &["get", "deployments", "-n", namespace]}, matcher, namespace).unwrap(); + assert_eq!("ahoy-hello-world", matched_result); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 2b45bf6..a4d8948 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,8 @@ mod gui; use color_eyre::{config::HookBuilder, eyre::Result}; use clap::{command, Parser}; +use crate::kubectl::KubectlRunnerAgent; + /// Program to execute kubectl commands on resources, using regex matching. #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -22,7 +24,7 @@ fn main() -> Result<()> { let args = Args::parse(); - let pod = kubectl::find_matching_pod(&args.matcher)?; + let pod = kubectl::find_matching_pod(&KubectlRunnerAgent{}, &args.matcher)?; Ok(gui::gui(pod)?) } From 9fb57ee8d392854b78a72f848a23429ec5a61922 Mon Sep 17 00:00:00 2001 From: jamesgiu Date: Sun, 27 Jul 2025 23:12:23 +1000 Subject: [PATCH 07/10] QK-XX more tests --- src/kubectl.rs | 59 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/src/kubectl.rs b/src/kubectl.rs index c5a7734..c2c6a54 100644 --- a/src/kubectl.rs +++ b/src/kubectl.rs @@ -8,6 +8,7 @@ use thiserror::Error; // Refactor to use KubectlRunner // Mock KubectlRunner for tests // More RustDoc +// Write tests pub trait KubectlRunner { fn run_commands(&self, args: &[&str]) -> Result; @@ -379,26 +380,62 @@ pub fn describe_pod(pod: &FoundPod) -> Result { #[cfg(test)] mod tests { - use crate::kubectl::{find_matching_deployment, KubectlRunner}; + use color_eyre::eyre; + use crate::kubectl::tests::eyre::eyre; + + use crate::kubectl::{find_matching_deployment, KubeError, KubectlRunner}; + + const EXPECTED_ERROR: &str = "error"; pub struct TestKubeCtlRunner<'a> { expected_args: &'a [&'a str] } + pub struct ErroringTestKubeCtlRunner<'a> { + expected_args: &'a [&'a str] + } + + impl KubectlRunner for TestKubeCtlRunner<'_> { + fn run_commands(&self, args: &[&str]) -> color_eyre::eyre::Result { + // Ensure args are as expected. + assert_eq!(args, self.expected_args); + Ok(String::from( + "NAME READY UP-TO-DATE AVAILABLE AGE\n".to_owned() + + "ahoy-hello-world 2/2 2 2 266d")) + } + } + + impl KubectlRunner for ErroringTestKubeCtlRunner<'_> { + fn run_commands(&self, args: &[&str]) -> color_eyre::eyre::Result { + // Ensure args are as expected. + assert_eq!(args, self.expected_args); + Err(eyre!(EXPECTED_ERROR)) + } + } + #[test] - fn test_find_matching_deployment_succeess() { + fn test_find_matching_deployment_success() { let matcher = "hello"; let namespace = "namespace"; - impl KubectlRunner for TestKubeCtlRunner<'_> { - fn run_commands(&self, args: &[&str]) -> color_eyre::eyre::Result { - // Ensure args are as expected. - assert_eq!(args, self.expected_args); - Ok(String::from( - "NAME READY UP-TO-DATE AVAILABLE AGE\n".to_owned() + - "ahoy-hello-world 2/2 2 2 266d")) - } - } let matched_result = find_matching_deployment(&TestKubeCtlRunner { expected_args: &["get", "deployments", "-n", namespace]}, matcher, namespace).unwrap(); assert_eq!("ahoy-hello-world", matched_result); } + + #[test] + fn test_find_matching_deployment_failure() { + let matcher = "goodbye"; + let namespace = "namespace"; + let matched_result = find_matching_deployment(&TestKubeCtlRunner { expected_args: &["get", "deployments", "-n", namespace]}, matcher, namespace); + assert!(matched_result.is_err()); + assert_eq!(KubeError::ResourceNotFoundError(matcher.to_string(), namespace.to_string()).to_string(), matched_result.err().unwrap().to_string()) + } + + #[test] + fn test_find_matching_deployment_err() { + let matcher = "goodbye"; + let namespace = "namespace"; + let matched_result = find_matching_deployment(&ErroringTestKubeCtlRunner { expected_args: &["get", "deployments", "-n", namespace]}, matcher, namespace); + assert!(matched_result.is_err()); + assert_eq!("error", matched_result.err().unwrap().to_string()) + } } \ No newline at end of file From 377edbd1442a45b6abe130a34fef95c30ca1bf9c Mon Sep 17 00:00:00 2001 From: jamesgiu Date: Sun, 27 Jul 2025 23:49:22 +1000 Subject: [PATCH 08/10] QK-XX more tests --- src/kubectl.rs | 160 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 125 insertions(+), 35 deletions(-) diff --git a/src/kubectl.rs b/src/kubectl.rs index c2c6a54..c200095 100644 --- a/src/kubectl.rs +++ b/src/kubectl.rs @@ -382,32 +382,46 @@ pub fn describe_pod(pod: &FoundPod) -> Result { mod tests { use color_eyre::eyre; use crate::kubectl::tests::eyre::eyre; + use color_eyre::eyre::{Result}; - use crate::kubectl::{find_matching_deployment, KubeError, KubectlRunner}; + use crate::kubectl::{find_matching_deployment, find_matching_pod, KubeError, KubectlRunner}; const EXPECTED_ERROR: &str = "error"; + static mut COUNTER: usize = 0; + + #[derive(Default)] + pub struct TestKubeCtlRunner<'a> { - expected_args: &'a [&'a str] + expected_args: Vec<&'a [&'a str]>, + pod_output: Option<&'a str>, } pub struct ErroringTestKubeCtlRunner<'a> { - expected_args: &'a [&'a str] + expected_args: &'a [&'a str], } impl KubectlRunner for TestKubeCtlRunner<'_> { - fn run_commands(&self, args: &[&str]) -> color_eyre::eyre::Result { - // Ensure args are as expected. - assert_eq!(args, self.expected_args); - Ok(String::from( - "NAME READY UP-TO-DATE AVAILABLE AGE\n".to_owned() + - "ahoy-hello-world 2/2 2 2 266d")) + fn run_commands(&self, args: &[&str]) -> Result { + unsafe { assert_eq!(args, self.expected_args[COUNTER]) }; + unsafe { COUNTER += 1 }; + + if args.contains(&"pods") { + Ok(String::from( + "namespace api-server-hello-123456\nnamespace2 something-else-abc", + )) + } else if args.contains(&"deployments") { + Ok(String::from( + "NAME READY UP-TO-DATE AVAILABLE AGE\nahoy-api-server 2/2 2 2 100d", + )) + } else { + Ok(self.pod_output.unwrap_or("").to_string()) + } } } impl KubectlRunner for ErroringTestKubeCtlRunner<'_> { - fn run_commands(&self, args: &[&str]) -> color_eyre::eyre::Result { - // Ensure args are as expected. + fn run_commands(&self, args: &[&str]) -> Result { assert_eq!(args, self.expected_args); Err(eyre!(EXPECTED_ERROR)) } @@ -415,27 +429,103 @@ mod tests { #[test] fn test_find_matching_deployment_success() { - let matcher = "hello"; - let namespace = "namespace"; - let matched_result = find_matching_deployment(&TestKubeCtlRunner { expected_args: &["get", "deployments", "-n", namespace]}, matcher, namespace).unwrap(); - assert_eq!("ahoy-hello-world", matched_result); - } - - #[test] - fn test_find_matching_deployment_failure() { - let matcher = "goodbye"; - let namespace = "namespace"; - let matched_result = find_matching_deployment(&TestKubeCtlRunner { expected_args: &["get", "deployments", "-n", namespace]}, matcher, namespace); - assert!(matched_result.is_err()); - assert_eq!(KubeError::ResourceNotFoundError(matcher.to_string(), namespace.to_string()).to_string(), matched_result.err().unwrap().to_string()) - } - - #[test] - fn test_find_matching_deployment_err() { - let matcher = "goodbye"; - let namespace = "namespace"; - let matched_result = find_matching_deployment(&ErroringTestKubeCtlRunner { expected_args: &["get", "deployments", "-n", namespace]}, matcher, namespace); - assert!(matched_result.is_err()); - assert_eq!("error", matched_result.err().unwrap().to_string()) - } -} \ No newline at end of file + unsafe { COUNTER = 0 }; + let matcher = "api"; + let namespace = "namespace"; + let matched_result = find_matching_deployment( + &mut TestKubeCtlRunner { + expected_args: vec!(&["get", "deployments", "-n", namespace]), + pod_output: None, + ..Default::default() + }, + matcher, + namespace, + ) + .unwrap(); + assert_eq!("ahoy-api-server", matched_result); + } + + #[test] + fn test_find_matching_deployment_failure() { + unsafe { COUNTER = 0 }; + let matcher = "goodbye"; + let namespace = "namespace"; + let matched_result = find_matching_deployment( + &mut TestKubeCtlRunner { + expected_args: vec!(&["get", "deployments", "-n", namespace]), + pod_output: None, + ..Default::default() + }, + matcher, + namespace, + ); + assert!(matched_result.is_err()); + assert_eq!( + KubeError::ResourceNotFoundError(matcher.to_string(), namespace.to_string()).to_string(), + matched_result.err().unwrap().to_string() + ) + } + + #[test] + fn test_find_matching_deployment_err() { + unsafe { COUNTER = 0 }; + let matcher = "goodbye"; + let namespace = "namespace"; + let matched_result = find_matching_deployment( + &mut ErroringTestKubeCtlRunner { + expected_args: &["get", "deployments", "-n", namespace], + }, + matcher, + namespace, + ); + assert!(matched_result.is_err()); + assert_eq!(EXPECTED_ERROR, matched_result.err().unwrap().to_string()) + } + + #[test] + fn test_find_matching_pod_success() { + unsafe { COUNTER = 0 }; + let matcher = "api-server"; + let matched_result = find_matching_pod(&mut TestKubeCtlRunner { + expected_args: vec!(&["get", "pods", "--all-namespaces"], &["get", "deployments", "-n", "namespace"]), + pod_output: None, + ..Default::default() + }, matcher) + .unwrap(); + + assert_eq!(matched_result.name, "api-server-hello-123456"); + assert_eq!(matched_result.namespace, "namespace"); + assert_eq!(matched_result.deployment, "ahoy-api-server"); + } + + #[test] + fn test_find_matching_pod_not_found() { + unsafe { COUNTER = 0 }; + let matcher = "nonexistent"; + + let result = find_matching_pod(&mut TestKubeCtlRunner { + expected_args: vec!(&["get", "pods", "--all-namespaces"], &["get", "deployments", "-n", "namespace"]), + pod_output: Some("namespace pod-abc\nnamespace2 something-else"), + ..Default::default() + }, matcher); + + assert!(result.is_err()); + assert_eq!( + KubeError::ResourceNotFoundError(matcher.to_string(), "all".to_string()).to_string(), + result.err().unwrap().to_string() + ); + } + + #[test] + fn test_find_matching_pod_kubectl_error() { + unsafe { COUNTER = 0 }; + let matcher = "error"; + + let result = find_matching_pod(&mut ErroringTestKubeCtlRunner { + expected_args: &["get", "pods", "--all-namespaces"], + }, matcher); + + assert!(result.is_err()); + assert_eq!(EXPECTED_ERROR, result.err().unwrap().to_string()); + } +} From 06d16493401622b5be776a3555a0148ba7c9e078 Mon Sep 17 00:00:00 2001 From: jamesgiu Date: Sun, 17 Aug 2025 11:36:05 +1000 Subject: [PATCH 09/10] QK-XX tests for get_pod_logs --- src/cli.rs | 6 ++-- src/gui.rs | 12 ++++---- src/kubectl.rs | 78 +++++++++++++++++++++++++++++++++++++------------- 3 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 1246d1c..3af832a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,9 +1,9 @@ use std::{fs, process::Command}; use color_eyre::{Result}; -use crate::kubectl::{self, FoundPod}; +use crate::kubectl::{self, FoundPod, KubectlRunner}; -pub fn open_in_vim(pod: &FoundPod) -> Result<()> { - let logs = kubectl::get_pod_logs(pod, false, false).unwrap(); +pub fn open_in_vim(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result<()> { + let logs = kubectl::get_pod_logs(runner, pod, false, false).unwrap(); let name = &pod.name; let fname = format!("/tmp/klog_{name}"); fs::write(&fname, logs).expect("Unable to write file"); diff --git a/src/gui.rs b/src/gui.rs index 6ffc1d0..b8b1576 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -91,14 +91,14 @@ fn run_app( terminal: &mut Terminal, mut app: App, tick_rate: Duration -) -> io::Result<()> { +) -> Result { let mut last_tick = Instant::now(); let mut fetch_new_logs = false; let mut fetch_prev_container_logs = false; let mut delete_pod_next_tick = false; let mut reset_scroll = true; let runner = KubectlRunnerAgent; - let mut text = kubectl::get_pod_logs(&app.target_pod, true, false).unwrap(); + let mut text = kubectl::get_pod_logs(&runner, &app.target_pod, true, false)?; let icons = ["🐝", "πŸ¦€", "πŸ‹", "🐧", "πŸ¦•", "🦐", "🐬", "🦞", "πŸ€–", "🐀", "πŸͺΏ"]; // Create a random number generator let mut rng = rand::rng(); @@ -117,13 +117,13 @@ fn run_app( } if fetch_prev_container_logs { - text = kubectl::get_pod_logs(&app.target_pod, true, true).unwrap(); + text = kubectl::get_pod_logs(&runner, &app.target_pod, true, true)?; fetch_prev_container_logs = false; reset_scroll = true; } if fetch_new_logs { - text = kubectl::get_pod_logs(&app.target_pod, true, false).unwrap(); + text = kubectl::get_pod_logs(&runner, &app.target_pod, true, false)?; fetch_new_logs = false; reset_scroll = true; } @@ -175,7 +175,7 @@ fn run_app( } } else { match key.code { - KeyCode::Char('q') => return Ok(()), + KeyCode::Char('q') => return Ok("quit".to_string()), KeyCode::Char('s') => { app.new_pod_search_pop_up = true; app.last_action = Some(InternalAction::Switch); @@ -220,7 +220,7 @@ fn run_app( }, KeyCode::Char('v') => { terminal.clear().unwrap(); - cli::open_in_vim(&app.target_pod).unwrap(); + cli::open_in_vim(&runner, &app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('l') => { diff --git a/src/kubectl.rs b/src/kubectl.rs index c200095..ceadb30 100644 --- a/src/kubectl.rs +++ b/src/kubectl.rs @@ -4,12 +4,6 @@ use color_eyre::eyre::{Context, Result}; use regex::Regex; use thiserror::Error; -// TODO -// Refactor to use KubectlRunner -// Mock KubectlRunner for tests -// More RustDoc -// Write tests - pub trait KubectlRunner { fn run_commands(&self, args: &[&str]) -> Result; } @@ -33,6 +27,8 @@ pub enum KubeError { /// Raised when a resource could not be found for a given matcher in a specified namespace. #[error("Resource not found with provided matcher: {0} in namespace {1}")] ResourceNotFoundError(String, String), + #[error("Execution not able to be performed on {0} in namespace {1}")] + ResourceExecutionIssue(String, String) } /// Represents a Kubernetes pod and its associated metadata. @@ -335,23 +331,23 @@ pub fn edit_deployment(pod: &FoundPod) -> Result<()> { /// println!("{}", logs); /// # Ok::<(), color_eyre::eyre::Report>(()) /// ``` -pub fn get_pod_logs(pod: &FoundPod, lite: bool, last_container: bool) -> Result { - let output = Command::new("kubectl") - .arg("logs") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg(if lite { "--tail=500" } else { "--tail=-1" }) - .arg(if last_container { +pub fn get_pod_logs(runner: &dyn KubectlRunner, pod: &FoundPod, lite: bool, last_container: bool) -> Result { + let output = runner.run_commands( + &["logs", &pod.name, "-n", &pod.namespace, if lite { "--tail=500" } else { "--tail=-1" }, if last_container { "--previous=true" } else { "--previous=false" - }) - .output() - .wrap_err("Failed to get pod logs")?; + }]); + + match output { + Ok(logs) => { + Ok(logs) + }, + Err(err) => { + Err(err.wrap_err(KubeError::ResourceExecutionIssue(pod.name.to_string(), pod.namespace.to_string())).into()) + } + } - let logs = String::from_utf8(output.stdout)?; - Ok(logs) } /// Describes the given pod using `kubectl describe`. @@ -381,7 +377,7 @@ pub fn describe_pod(pod: &FoundPod) -> Result { #[cfg(test)] mod tests { use color_eyre::eyre; - use crate::kubectl::tests::eyre::eyre; + use crate::kubectl::{get_pod_logs, tests::eyre::eyre, FoundPod}; use color_eyre::eyre::{Result}; use crate::kubectl::{find_matching_deployment, find_matching_pod, KubeError, KubectlRunner}; @@ -528,4 +524,46 @@ mod tests { assert!(result.is_err()); assert_eq!(EXPECTED_ERROR, result.err().unwrap().to_string()); } + + #[test] + fn test_get_pod_logs_success() { + unsafe { COUNTER = 0 }; + let pod = FoundPod { + name: "eh".to_string(), + namespace: "namespace".to_string(), + deployment: "eh".to_string(), + }; + + let binding = ["logs", &pod.name, "-n", &pod.namespace, "--tail=-1", "--previous=false"]; + let test_kube_ctl_runner = TestKubeCtlRunner { + expected_args: vec!(&binding), + pod_output: Some("these are some logs") + }; + + let result = get_pod_logs(&test_kube_ctl_runner, &pod, false, false); + + assert!(result.is_ok()); + assert_eq!("these are some logs", result.unwrap().to_string()) + } + + + #[test] + fn test_get_pod_logs_error() { + unsafe { COUNTER = 0 }; + let pod = FoundPod { + name: "eh".to_string(), + namespace: "namespace".to_string(), + deployment: "eh".to_string(), + }; + + let binding = &["logs", &pod.name, "-n", &pod.namespace, "--tail=-1", "--previous=false"]; + let test_kube_ctl_runner = ErroringTestKubeCtlRunner { + expected_args: binding, + }; + + let result = get_pod_logs(&test_kube_ctl_runner, &pod, false, false); + + assert!(result.is_err()); + assert_eq!(KubeError::ResourceExecutionIssue(pod.name, pod.namespace).to_string(), result.err().unwrap().to_string()) + } } From a2df9efd23382e1d7f453a0237d4c3c6a11d77b4 Mon Sep 17 00:00:00 2001 From: jamesgiu Date: Sun, 24 Aug 2025 00:12:03 +1000 Subject: [PATCH 10/10] QK-XX use runner for all --- src/gui.rs | 14 ++-- src/kubectl.rs | 197 +++++++++++++++++-------------------------------- 2 files changed, 76 insertions(+), 135 deletions(-) diff --git a/src/gui.rs b/src/gui.rs index b8b1576..db31b3d 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -131,7 +131,7 @@ fn run_app( if delete_pod_next_tick { text = text + "\nDeleted :(. Press 'q' to quit."; app.show_pod_deleted_pop_up = true; - kubectl::delete_pod(&app.target_pod).unwrap(); + kubectl::delete_pod(&runner, &app.target_pod).unwrap(); delete_pod_next_tick = false; reset_scroll = true; } @@ -189,33 +189,33 @@ fn run_app( app.last_action = Some(InternalAction::Purge); }, KeyCode::Char('d') => { - text = kubectl::describe_pod(&app.target_pod).unwrap(); + text = kubectl::describe_pod(&runner, &app.target_pod).unwrap(); app.vertical_scroll = 0; app.last_action = Some(InternalAction::ViewDesc); }, KeyCode::Char('E') => { terminal.clear().unwrap(); - kubectl::edit_deployment(&app.target_pod).unwrap(); + kubectl::edit_deployment(&runner, &app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('w') => { - text = kubectl::get_pods(&app.target_pod).unwrap(); + text = kubectl::get_pods(&runner, &app.target_pod).unwrap(); app.vertical_scroll = 0; app.last_action = Some(InternalAction::World); }, KeyCode::Char('W') => { - text = kubectl::get_all(&app.target_pod).unwrap(); + text = kubectl::get_all(&runner, &app.target_pod).unwrap(); app.vertical_scroll = 0; app.last_action = Some(InternalAction::World); }, KeyCode::Char('e') => { terminal.clear().unwrap(); - kubectl::exec_into_pod(&app.target_pod).unwrap(); + kubectl::exec_into_pod(&runner, &app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('b') => { terminal.clear().unwrap(); - kubectl::debug_pod(&app.target_pod).unwrap(); + kubectl::debug_pod(&runner, &app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('v') => { diff --git a/src/kubectl.rs b/src/kubectl.rs index ceadb30..e806962 100644 --- a/src/kubectl.rs +++ b/src/kubectl.rs @@ -1,4 +1,4 @@ -use std::process::{Command, Stdio}; +use std::{io::Write, process::{Command, Stdio}}; use color_eyre::eyre::{Context, Result}; use regex::Regex; @@ -6,6 +6,7 @@ use thiserror::Error; pub trait KubectlRunner { fn run_commands(&self, args: &[&str]) -> Result; + fn spawn_shell(&self, args: &[&str]) -> Result<()>; } pub struct KubectlRunnerAgent; @@ -19,6 +20,17 @@ impl KubectlRunner for KubectlRunnerAgent { Ok(output) } + + fn spawn_shell(&self, args: &[&str]) -> Result<()> { + Command::new("kubectl") + .args(args) + .spawn() + .wrap_err("Could not run commands")? + .wait() + .wrap_err("could not spawn process")?; + + Ok(()) + } } /// Custom error type for Kubernetes resource matching operations. @@ -127,51 +139,23 @@ pub fn find_matching_pod(runner: &dyn KubectlRunner, matcher: &str) -> Result Result<()> { - let image_name = String::from_utf8( - Command::new("kubectl") - .arg("get") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("-o=jsonpath={.spec.containers[0].image}") - .output() - .wrap_err("Failed to get image name")? - .stdout, - )? - .replace("[ ", ""); - - let container_name = String::from_utf8( - Command::new("kubectl") - .arg("get") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("-o=jsonpath={.spec.containers[0].name}") - .output() - .wrap_err("Failed to get container name")? - .stdout, - )? - .replace("[ ", ""); - - Command::new("kubectl") - .arg("debug") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("-it") - .arg(format!("--image={}", &image_name)) - .arg(format!("--target={}", &container_name)) - .arg("--") - .arg("sh") - .spawn() - .wrap_err("Failed to spawn kubectl debug")? - .wait() - .wrap_err("Failed to wait for kubectl debug")?; - - Ok(()) +pub fn debug_pod(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result<()> { + let image_name = runner.run_commands(&[ + "get", "pod", &pod.name, "-n", &pod.namespace, + "-o=jsonpath={.spec.containers[0].image}", + ])?; + + let container_name = runner.run_commands(&[ + "get", "pod", &pod.name, "-n", &pod.namespace, + "-o=jsonpath={.spec.containers[0].name}", + ])?; + + runner.spawn_shell(&[ + "debug", &pod.name, "-n", &pod.namespace, "-it", + &format!("--image={}", image_name), + &format!("--target={}", container_name), + "--", "sh", + ]) } /// Starts an interactive shell session inside a running pod container. @@ -181,22 +165,10 @@ pub fn debug_pod(pod: &FoundPod) -> Result<()> { /// /// # Errors /// Returns an error if the `kubectl exec` command fails. -pub fn exec_into_pod(pod: &FoundPod) -> Result<()> { - Command::new("kubectl") - .arg("exec") - .arg("--stdin") - .arg("--tty") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("--") - .arg("/bin/sh") - .spawn() - .wrap_err("Failed to exec into pod")? - .wait() - .wrap_err("Failed to wait for exec command")?; - - Ok(()) +pub fn exec_into_pod(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result<()> { + runner.spawn_shell(&[ + "exec", "--stdin", "--tty", &pod.name, "-n", &pod.namespace, "--", "/bin/sh", + ]) } /// Deletes the given pod without waiting for completion. @@ -209,19 +181,10 @@ pub fn exec_into_pod(pod: &FoundPod) -> Result<()> { /// /// # Errors /// Returns an error if the command fails or the output can't be decoded. -pub fn delete_pod(pod: &FoundPod) -> Result { - let output = Command::new("kubectl") - .arg("delete") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .arg("--wait=false") - .output() - .wrap_err("Failed to delete pod")?; - - let delete = String::from_utf8(output.stdout)?; - Ok(delete) +pub fn delete_pod(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result { + runner.run_commands(&[ + "delete", "pod", &pod.name, "-n", &pod.namespace, "--wait=false", + ]) } /// Retrieves a reversed and formatted list of pods sorted by start time in the given namespace. @@ -234,26 +197,25 @@ pub fn delete_pod(pod: &FoundPod) -> Result { /// /// # Errors /// Returns an error if the `kubectl` or `tac` commands fail or output can't be parsed. -pub fn get_pods(pod: &FoundPod) -> Result { - let output = Command::new("kubectl") - .arg("get") - .arg("pods") - .arg("-n") - .arg(&pod.namespace) - .arg("--sort-by=.status.startTime") - .arg("--no-headers") +pub fn get_pods(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result { + let pods_output = runner.run_commands(&[ + "get", "pods", "-n", &pod.namespace, + "--sort-by=.status.startTime", "--no-headers", + ])?; + + let mut tac = Command::new("tac") + .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() - .wrap_err("Failed to run kubectl get pods")?; + .wrap_err("Failed to run tac")?; - let tac = Command::new("tac") - .stdin(output.stdout.ok_or_else(|| { - color_eyre::eyre::eyre!("Failed to capture stdout from kubectl get pods") - })?) - .output() - .wrap_err("Failed to run tac on pods output")?; + if let Some(stdin) = tac.stdin.as_mut() { + stdin.write_all(pods_output.as_bytes())?; + } - let pods = String::from_utf8(tac.stdout)? + let output = tac.wait_with_output().wrap_err("Failed to get tac output")?; + + let pods = String::from_utf8(output.stdout)? .replace("Running", "πŸƒ Running") .replace("Error", "❌ Error") .replace("Completed", "βœ… Completed") @@ -275,18 +237,8 @@ pub fn get_pods(pod: &FoundPod) -> Result { /// /// # Errors /// Returns an error if the command fails or output is invalid. -pub fn get_all(pod: &FoundPod) -> Result { - let output = Command::new("kubectl") - .arg("get") - .arg("all") - .arg("-n") - .arg(&pod.namespace) - .arg("--no-headers") - .output() - .wrap_err("Failed to get all resources")?; - - let all = String::from_utf8(output.stdout)?; - Ok(all) +pub fn get_all(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result { + runner.run_commands(&["get", "all", "-n", &pod.namespace, "--no-headers"]) } /// Opens the deployment of the given pod in an editor. @@ -296,19 +248,10 @@ pub fn get_all(pod: &FoundPod) -> Result { /// /// # Errors /// Returns an error if `kubectl edit` fails to spawn or complete. -pub fn edit_deployment(pod: &FoundPod) -> Result<()> { - Command::new("kubectl") - .arg("edit") - .arg("deployment") - .arg(&pod.deployment) - .arg("-n") - .arg(&pod.namespace) - .spawn() - .wrap_err("Failed to spawn kubectl edit")? - .wait() - .wrap_err("Failed to wait for edit process")?; - - Ok(()) +pub fn edit_deployment(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result<()> { + runner.spawn_shell(&[ + "edit", "deployment", &pod.deployment, "-n", &pod.namespace, + ]) } /// Fetches logs from a given pod, optionally from the last container or limiting output. @@ -360,18 +303,8 @@ pub fn get_pod_logs(runner: &dyn KubectlRunner, pod: &FoundPod, lite: bool, last /// /// # Errors /// Returns an error if the command fails or the output can't be decoded. -pub fn describe_pod(pod: &FoundPod) -> Result { - let output = Command::new("kubectl") - .arg("describe") - .arg("pod") - .arg(&pod.name) - .arg("-n") - .arg(&pod.namespace) - .output() - .wrap_err("Failed to describe pod")?; - - let describe = String::from_utf8(output.stdout)?; - Ok(describe) +pub fn describe_pod(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result { + runner.run_commands(&["describe", "pod", &pod.name, "-n", &pod.namespace]) } #[cfg(test)] @@ -414,6 +347,10 @@ mod tests { Ok(self.pod_output.unwrap_or("").to_string()) } } + + fn spawn_shell(&self, args: &[&str]) -> Result<()> { + todo!() + } } impl KubectlRunner for ErroringTestKubeCtlRunner<'_> { @@ -421,6 +358,10 @@ mod tests { assert_eq!(args, self.expected_args); Err(eyre!(EXPECTED_ERROR)) } + + fn spawn_shell(&self, args: &[&str]) -> Result<()> { + todo!() + } } #[test]