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 new file mode 100644 index 0000000..3af832a --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,20 @@ +use std::{fs, process::Command}; +use color_eyre::{Result}; +use crate::kubectl::{self, FoundPod, KubectlRunner}; + +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"); + 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 58% rename from src/klog.rs rename to src/gui.rs index 4f43e12..db31b3d 100644 --- a/src/klog.rs +++ b/src/gui.rs @@ -1,18 +1,40 @@ -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}; 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; 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, KubectlRunnerAgent}; +use crate::cli::{self}; + +pub fn render_action_text<'a>(text: &'a str, action: InternalAction, last_action: &Option) -> Span<'a> { + if let Some(last_action) = last_action { + if *last_action == action { + return format!("{text}").blue(); + } + } + + format!("{text}").white() +} + +#[derive(PartialEq, Copy, Clone)] +pub enum InternalAction { + FetchLogs, + LastLogs, + ViewDesc, + Purge, + World, + Switch +} #[derive(Default)] struct App { @@ -21,13 +43,15 @@ 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, pub emoji: String, + pub last_action: Option, } -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 @@ -43,6 +67,7 @@ pub(crate) fn klog(target: FoundPod) -> anyhow::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); @@ -62,221 +87,18 @@ 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, 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 mut text = get_pod_logs(&app.target_pod, true, false).unwrap(); + let runner = KubectlRunnerAgent; + 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(); @@ -295,13 +117,13 @@ fn run_app( } if fetch_prev_container_logs { - text = 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 = 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; } @@ -309,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; - delete_pod(&app.target_pod).unwrap(); + kubectl::delete_pod(&runner, &app.target_pod).unwrap(); delete_pod_next_tick = false; reset_scroll = true; } @@ -329,11 +151,22 @@ fn run_app( app.input_text.clear(); } KeyCode::Enter => { - app.new_pod_search_pop_up = false; - app.target_pod = find_matching_pod(app.input_text.as_str()).unwrap(); - fetch_new_logs = true; - app.vertical_scroll = 0; - app.input_text.clear(); + 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; + 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(); @@ -342,50 +175,57 @@ 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.new_pod_search_pop_up = true; + app.last_action = Some(InternalAction::Switch); } KeyCode::Char('f') => { - fetch_new_logs = true + fetch_new_logs = true; + app.last_action = Some(InternalAction::FetchLogs); }, KeyCode::Char('p') => { delete_pod_next_tick = true; + app.last_action = Some(InternalAction::Purge); }, KeyCode::Char('d') => { - text = 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(); - edit_deployment(&app.target_pod).unwrap(); + kubectl::edit_deployment(&runner, &app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('w') => { - text = 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 = 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(); - 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(); - debug_pod(&app.target_pod).unwrap(); + kubectl::debug_pod(&runner, &app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('v') => { terminal.clear().unwrap(); - open_in_vim(&app.target_pod).unwrap(); + cli::open_in_vim(&runner, &app.target_pod).unwrap(); terminal.clear().unwrap(); }, KeyCode::Char('l') => { fetch_prev_container_logs = true; + app.last_action = Some(InternalAction::LastLogs); }, KeyCode::Char('j') | KeyCode::Down => { if app.vertical_scroll + 1 < text.lines().count() { @@ -427,8 +267,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 ", 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), @@ -444,10 +287,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 ", 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(format!("πŸ—ΊοΈ [W/w]orld [s]witch βš™οΈ").white()).right_aligned()) - ) + .title_bottom(Line::from(vec![ + 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 }); @@ -470,7 +320,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()) diff --git a/src/kubectl.rs b/src/kubectl.rs new file mode 100644 index 0000000..e806962 --- /dev/null +++ b/src/kubectl.rs @@ -0,0 +1,510 @@ +use std::{io::Write, process::{Command, Stdio}}; + +use color_eyre::eyre::{Context, Result}; +use regex::Regex; +use thiserror::Error; + +pub trait KubectlRunner { + fn run_commands(&self, args: &[&str]) -> Result; + fn spawn_shell(&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) + } + + 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. +#[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), + #[error("Execution not able to be performed on {0} in namespace {1}")] + ResourceExecutionIssue(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, +} + +/// 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, "") + .to_string(); + + let re = Regex::new(&format!(r"[A-Za-z-]*{sanitised_matcher}[A-Za-z-]* "))?; + + match re.captures(&deployments) { + Some(matches) => { + let deployment: String = matches[0].to_string().replace(" ", ""); + Ok(deployment) + } + None => Err(KubeError::ResourceNotFoundError( + sanitised_matcher, + namespace.to_string(), + ) + .into()), + } +} + +/// 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-]+)" + ))?; + + match re.captures(&pods) { + Some(matches) => { + 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(runner, &matcher, &ns)?; + + Ok(FoundPod { + name: pod, + namespace: ns, + deployment, + }) + } + None => Err(KubeError::ResourceNotFoundError( + matcher.to_string(), + "all".to_string(), + ) + .into()), + } +} + +/// 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(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. +/// +/// # Arguments +/// * `pod` - A reference to the target `FoundPod`. +/// +/// # Errors +/// Returns an error if the `kubectl exec` command fails. +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. +/// +/// # 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(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. +/// +/// # 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(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 tac")?; + + if let Some(stdin) = tac.stdin.as_mut() { + stdin.write_all(pods_output.as_bytes())?; + } + + 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") + .replace("Terminating", "πŸ’€οΈ Terminating") + .replace("CrashLoopBackOff", "πŸ”₯ CrashLoopBackOff") + .replace("ImagePullBackOff", "πŸ‘» ImagePullBackOff") + .replace("ContainerCreating", "✨️ ContainerCreating"); + + 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(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. +/// +/// # 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(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. +/// +/// # 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(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" + }]); + + match output { + Ok(logs) => { + Ok(logs) + }, + Err(err) => { + Err(err.wrap_err(KubeError::ResourceExecutionIssue(pod.name.to_string(), pod.namespace.to_string())).into()) + } + } + +} + +/// 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(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result { + runner.run_commands(&["describe", "pod", &pod.name, "-n", &pod.namespace]) +} + +#[cfg(test)] +mod tests { + use color_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}; + + const EXPECTED_ERROR: &str = "error"; + + static mut COUNTER: usize = 0; + + #[derive(Default)] + + pub struct TestKubeCtlRunner<'a> { + expected_args: Vec<&'a [&'a str]>, + pod_output: Option<&'a str>, + } + + pub struct ErroringTestKubeCtlRunner<'a> { + expected_args: &'a [&'a str], + } + + impl KubectlRunner for TestKubeCtlRunner<'_> { + 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()) + } + } + + fn spawn_shell(&self, args: &[&str]) -> Result<()> { + todo!() + } + } + + impl KubectlRunner for ErroringTestKubeCtlRunner<'_> { + fn run_commands(&self, args: &[&str]) -> Result { + assert_eq!(args, self.expected_args); + Err(eyre!(EXPECTED_ERROR)) + } + + fn spawn_shell(&self, args: &[&str]) -> Result<()> { + todo!() + } + } + + #[test] + fn test_find_matching_deployment_success() { + 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()); + } + + #[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()) + } +} diff --git a/src/main.rs b/src/main.rs index aaf2651..a4d8948 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ -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}; + +use crate::kubectl::KubectlRunnerAgent; /// Program to execute kubectl commands on resources, using regex matching. #[derive(Parser, Debug)] @@ -14,68 +15,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(); + 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()?; - let mut re = Regex::new(&format!(r"(\b.*\b)( .*{matcher}.*-[0-9A-Za-z-]+)")).unwrap(); - - // 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(&KubectlRunnerAgent{}, &args.matcher)?; - Ok(found_pod) + Ok(gui::gui(pod)?) }