diff --git a/Cargo.toml b/Cargo.toml index c0694c9..7639ab6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,6 @@ color-eyre = "0.6.3" clap = { version = "4.5.4", features = ["derive"] } rand = "0.9.0" thiserror = "2.0.12" + +[dev-dependencies] +assert_cmd = "2" \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index 3af832a..cf6484f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -17,4 +17,6 @@ pub fn open_in_vim(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result<()> { }; Ok(()) -} \ No newline at end of file +} + + diff --git a/src/gui.rs b/src/gui.rs index db31b3d..f12ee30 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, KubectlRunnerAgent}; +use crate::kubectl::{self, FoundPod, KubectlRunnerAgent, get_pod_status}; use crate::cli::{self}; pub fn render_action_text<'a>(text: &'a str, action: InternalAction, last_action: &Option) -> Span<'a> { @@ -48,6 +48,7 @@ struct App { pub input_text: String, pub target_pod: FoundPod, pub emoji: String, + pub pod_status: String, pub last_action: Option, } @@ -109,6 +110,8 @@ fn run_app( app.emoji = emoji.to_string(); loop { + app.pod_status = get_pod_status(&runner, &app.target_pod)?; + if reset_scroll { if text.lines().count() > 0 { app.vertical_scroll = text.lines().count() - 1; @@ -286,7 +289,7 @@ fn ui(f: &mut Frame, app: &mut App, text: &str) { .gray() .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!("{0} {pod_ns}/{pod_deployment}/{pod_name} ({1})", app.emoji, app.pod_status)).left_aligned().bold().white()) .title_top(Line::from(vec![ render_action_text("🔎 [d]esc ", InternalAction::ViewDesc, last_action), Span::from("💻 [e]xec "), diff --git a/src/kubectl.rs b/src/kubectl.rs index e806962..c1d2607 100644 --- a/src/kubectl.rs +++ b/src/kubectl.rs @@ -87,6 +87,29 @@ pub fn find_matching_deployment(runner: &dyn KubectlRunner, matcher: &str, names } } +pub fn get_pod_status(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result { + // Get pod status + let status_regex = Regex::new(&format!( + r"Status:\s+[0-9A-Za-z-]+" + ))?; + + let desc = describe_pod(runner, &pod)?; + + match status_regex.captures(&desc) { + Some(matched_term) => { + Ok( + pod_status_decorator(matched_term.get(0) + .ok_or_else( || color_eyre::eyre::eyre!("No pod status found"))? + .as_str() + .to_string() + .replace("Status:", "") + .replace(" ", "")) + ) + }, + None => Err(KubeError::ResourceExecutionIssue(pod.name.clone(), pod.namespace.clone()).into()), + } +} + /// Finds a pod by using a matcher string across all namespaces. /// /// # Arguments @@ -215,18 +238,22 @@ pub fn get_pods(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result { 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"); - + let pods = pod_status_decorator(String::from_utf8(output.stdout)?); + Ok(pods) } +fn pod_status_decorator(status: String) -> String { + return status + .replace("Running", "🏃 Running") + .replace("Error", "❌ Error") + .replace("Completed", "✅ Completed") + .replace("Terminating", "💀️ Terminating") + .replace("CrashLoopBackOff", "🔥 CrashLoopBackOff") + .replace("ImagePullBackOff", "👻 ImagePullBackOff") + .replace("ContainerCreating", "✨️ ContainerCreating"); +} + /// Lists all resources in the pod's namespace (no headers). /// /// # Arguments @@ -287,7 +314,7 @@ pub fn get_pod_logs(runner: &dyn KubectlRunner, pod: &FoundPod, lite: bool, last Ok(logs) }, Err(err) => { - Err(err.wrap_err(KubeError::ResourceExecutionIssue(pod.name.to_string(), pod.namespace.to_string())).into()) + Err(err.wrap_err(KubeError::ResourceExecutionIssue(pod.name.to_string(), pod.namespace.to_string()))) } } @@ -310,7 +337,7 @@ pub fn describe_pod(runner: &dyn KubectlRunner, pod: &FoundPod) -> Result Result<()> { - todo!() + unsafe { assert_eq!(args, self.expected_args[COUNTER]) }; + unsafe { COUNTER += 1 }; + + Ok(()) } } @@ -360,7 +391,8 @@ mod tests { } fn spawn_shell(&self, args: &[&str]) -> Result<()> { - todo!() + assert_eq!(args, self.expected_args); + Err(eyre!(EXPECTED_ERROR)) } } @@ -372,8 +404,7 @@ mod tests { let matched_result = find_matching_deployment( &mut TestKubeCtlRunner { expected_args: vec!(&["get", "deployments", "-n", namespace]), - pod_output: None, - ..Default::default() + pod_output: None }, matcher, namespace, @@ -390,8 +421,7 @@ mod tests { let matched_result = find_matching_deployment( &mut TestKubeCtlRunner { expected_args: vec!(&["get", "deployments", "-n", namespace]), - pod_output: None, - ..Default::default() + pod_output: None }, matcher, namespace, @@ -426,7 +456,6 @@ mod tests { 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(); @@ -487,7 +516,6 @@ mod tests { assert_eq!("these are some logs", result.unwrap().to_string()) } - #[test] fn test_get_pod_logs_error() { unsafe { COUNTER = 0 }; @@ -507,4 +535,396 @@ mod tests { assert!(result.is_err()); assert_eq!(KubeError::ResourceExecutionIssue(pod.name, pod.namespace).to_string(), result.err().unwrap().to_string()) } + + #[test] + fn test_exec_into_pod_success() { + unsafe { COUNTER = 0 }; + + let expected_pod = FoundPod { + name: "pod".to_string(), + namespace: "namespace".to_string(), + deployment: "deployment".to_string() + }; + + let args = ["exec", "--stdin", "--tty", &expected_pod.name, "-n", &expected_pod.namespace, "--", "/bin/sh"]; + + let test_kubectl_runner = TestKubeCtlRunner { + expected_args: vec!(&args), + pod_output: None, + }; + + let result = exec_into_pod(&test_kubectl_runner, &expected_pod); + + assert!(result.is_ok()); + } + + #[test] + fn test_exec_into_pod_failure() { + unsafe { COUNTER = 0 }; + + let expected_pod = FoundPod { + name: "pod".to_string(), + namespace: "namespace".to_string(), + deployment: "deployment".to_string() + }; + + let args = &["exec", "--stdin", "--tty", &expected_pod.name, "-n", &expected_pod.namespace, "--", "/bin/sh"]; + + let test_kubectl_runner = ErroringTestKubeCtlRunner { + expected_args: args + }; + + let result = exec_into_pod(&test_kubectl_runner, &expected_pod); + + assert!(result.is_err()); + } + + #[test] + fn test_describe_pod_success() { + unsafe { COUNTER = 0 }; + + let expected_pod = FoundPod { + name: "pod".to_string(), + namespace: "namespace".to_string(), + deployment: "deployment".to_string() + }; + + let args = ["describe", "pod", &expected_pod.name, "-n", &expected_pod.namespace]; + + let test_kubectl_runner = TestKubeCtlRunner { + expected_args: vec!(&args), + pod_output: Some("some description"), + }; + + let result = describe_pod(&test_kubectl_runner, &expected_pod); + + assert!(result.is_ok()); + assert_eq!(test_kubectl_runner.pod_output.unwrap(), result.unwrap()) + } + + #[test] + fn test_describe_pod_failure() { + unsafe { COUNTER = 0 }; + + let expected_pod = FoundPod { + name: "pod".to_string(), + namespace: "namespace".to_string(), + deployment: "deployment".to_string() + }; + + let args = &["describe", "pod", &expected_pod.name, "-n", &expected_pod.namespace]; + + let test_kubectl_runner = ErroringTestKubeCtlRunner { + expected_args: args + }; + + let result = describe_pod(&test_kubectl_runner, &expected_pod); + + assert!(result.is_err()); + } + + #[test] + fn test_get_all_pods_success() { + unsafe { COUNTER = 0 }; + + let expected_pod = FoundPod { + name: "pod".to_string(), + namespace: "namespace".to_string(), + deployment: "deployment".to_string() + }; + + let args = ["get", "all", "-n", &expected_pod.namespace, "--no-headers"]; + + let test_kubectl_runner = TestKubeCtlRunner { + expected_args: vec!(&args), + pod_output: Some("all pods"), + }; + + let result = get_all(&test_kubectl_runner, &expected_pod); + + assert!(result.is_ok()); + assert_eq!(test_kubectl_runner.pod_output.unwrap(), result.unwrap()) + } + + #[test] + fn test_get_all_pods_failure() { + unsafe { COUNTER = 0 }; + + let expected_pod = FoundPod { + name: "pod".to_string(), + namespace: "namespace".to_string(), + deployment: "deployment".to_string() + }; + + let args = &["get", "all", "-n", &expected_pod.namespace, "--no-headers"]; + + let test_kubectl_runner = ErroringTestKubeCtlRunner { + expected_args: args + }; + + let result = get_all(&test_kubectl_runner, &expected_pod); + + assert!(result.is_err()); + } + + #[test] + fn test_delete_pod_success() { + unsafe { COUNTER = 0 }; + + let expected_pod = FoundPod { + name: "pod".to_string(), + namespace: "namespace".to_string(), + deployment: "deployment".to_string() + }; + + let args = ["delete", "pod", &expected_pod.name, "-n", &expected_pod.namespace, "--wait=false"]; + + let test_kubectl_runner = TestKubeCtlRunner { + expected_args: vec!(&args), + pod_output: None, + }; + + let result = delete_pod(&test_kubectl_runner, &expected_pod); + + assert!(result.is_ok()); + } + + #[test] + fn test_delete_pod_failure() { + unsafe { COUNTER = 0 }; + + let expected_pod = FoundPod { + name: "pod".to_string(), + namespace: "namespace".to_string(), + deployment: "deployment".to_string() + }; + + let args = &["delete", "pod", &expected_pod.name, "-n", &expected_pod.namespace, "--wait=false"]; + + let test_kubectl_runner = ErroringTestKubeCtlRunner { + expected_args: args + }; + + let result = delete_pod(&test_kubectl_runner, &expected_pod); + + assert!(result.is_err()); + } + + #[test] + fn test_debug_pod_success() { + unsafe { COUNTER = 0 }; + + let pod = FoundPod { + name: "my-pod".to_string(), + namespace: "my-ns".to_string(), + deployment: "my-deploy".to_string(), + }; + + + let get_image = [ + "get", "pod", &pod.name, "-n", &pod.namespace, + "-o=jsonpath={.spec.containers[0].image}", + ]; + + + let get_container = [ + "get", "pod", &pod.name, "-n", &pod.namespace, + "-o=jsonpath={.spec.containers[0].name}", + ]; + + let debug_arg = [ + "debug", &pod.name, "-n", &pod.namespace, "-it", + "--image=a-fake-image", + "--target=a-fake-container", + "--", "sh", + ]; + + let expected_calls: Vec<&[&str]> = vec![ + &get_image, + &get_container, + &debug_arg, + ]; + + struct DebugRunner<'a> { + calls: Vec<&'a [&'a str]>, + } + + impl<'a> KubectlRunner for DebugRunner<'a> { + fn run_commands(&self, args: &[&str]) -> Result { + unsafe { + assert_eq!(args, self.calls[COUNTER]); + COUNTER += 1; + } + + if args.contains(&"-o=jsonpath={.spec.containers[0].image}") { + return Ok("a-fake-image".into()); + } + if args.contains(&"-o=jsonpath={.spec.containers[0].name}") { + return Ok("a-fake-container".into()); + } + Ok(String::new()) + } + + fn spawn_shell(&self, args: &[&str]) -> Result<()> { + unsafe { + assert_eq!(args, self.calls[COUNTER]); + COUNTER += 1; + } + Ok(()) + } + } + + let runner = DebugRunner { + calls: vec![ + expected_calls[0], + expected_calls[1], + expected_calls[2], + ], + }; + + let result = debug_pod(&runner, &pod); + + assert!(result.is_ok()); + } + + #[test] + fn test_debug_pod_failure() { + unsafe { COUNTER = 0 }; + + let pod = FoundPod { + name: "bad-pod".to_string(), + namespace: "ns".to_string(), + deployment: "dep".to_string(), + }; + + // First command should fail + let args = &[ + "get", "pod", &pod.name, "-n", &pod.namespace, + "-o=jsonpath={.spec.containers[0].image}", + ]; + + let runner = ErroringTestKubeCtlRunner { + expected_args: args, + }; + + let result = debug_pod(&runner, &pod); + + assert!(result.is_err()); + assert_eq!(EXPECTED_ERROR, result.err().unwrap().to_string()); + } + + #[test] + fn test_edit_deployment_success() { + unsafe { COUNTER = 0 }; + + let pod = FoundPod { + name: "pod".to_string(), + namespace: "namespace".to_string(), + deployment: "my-deploy".to_string(), + }; + + let expected = ["edit", "deployment", &pod.deployment, "-n", &pod.namespace]; + + let runner = TestKubeCtlRunner { + expected_args: vec!(&expected), + pod_output: None, + }; + + let result = edit_deployment(&runner, &pod); + + assert!(result.is_ok()); + } + + #[test] + fn test_edit_deployment_failure() { + unsafe { COUNTER = 0 }; + + let pod = FoundPod { + name: "pod".to_string(), + namespace: "ns".to_string(), + deployment: "my-deploy".to_string(), + }; + + let args = &["edit", "deployment", &pod.deployment, "-n", &pod.namespace]; + + let runner = ErroringTestKubeCtlRunner { expected_args: args }; + + let result = edit_deployment(&runner, &pod); + + assert!(result.is_err()); + } + + #[test] + fn test_get_pods_success() { + pub struct GetPodsTestKubeCtlRunner<'a> { + expected_args: Vec<&'a [&'a str]>, + pod_output: Option<&'a str>, + } + + impl KubectlRunner for GetPodsTestKubeCtlRunner<'_> { + fn run_commands(&self, args: &[&str]) -> Result { + unsafe { assert_eq!(args, self.expected_args[COUNTER]) }; + unsafe { COUNTER += 1 }; + + Ok(self.pod_output.unwrap_or("").to_string()) + } + + fn spawn_shell(&self, _args: &[&str]) -> Result<()> { + todo!() + } + } + + unsafe { COUNTER = 0 }; + + let pod = FoundPod { + name: "ignored".to_string(), + namespace: "ns".to_string(), + deployment: "ignore".to_string(), + }; + + let kubectl_args = [ + "get", "pods", "-n", &pod.namespace, + "--sort-by=.status.startTime", "--no-headers", + ]; + + let sample_output = "pod-a Running\npod-b Completed\npod-c Error"; + + let runner = GetPodsTestKubeCtlRunner { + expected_args: vec!(&kubectl_args), + pod_output: Some(sample_output), + }; + + let result = get_pods(&runner, &pod).unwrap(); + + // After tac: reverse order + emoji substitutions + assert!(result.contains("🏃 Running")); + assert!(result.contains("❌ Error")); + assert!(result.contains("✅ Completed")); + + // Should be reversed by tac + assert!(result.starts_with("pod-c")); + } + + #[test] + fn test_get_pods_failure() { + unsafe { COUNTER = 0 }; + + let pod = FoundPod { + name: "a".to_string(), + namespace: "ns".to_string(), + deployment: "b".to_string(), + }; + + let args = &[ + "get", "pods", "-n", &pod.namespace, + "--sort-by=.status.startTime", "--no-headers", + ]; + + let runner = ErroringTestKubeCtlRunner { expected_args: args }; + + let result = get_pods(&runner, &pod); + + assert!(result.is_err()); + } + } diff --git a/tests/test.rs b/tests/test.rs new file mode 100644 index 0000000..c9025e6 --- /dev/null +++ b/tests/test.rs @@ -0,0 +1,7 @@ +use assert_cmd::Command; + +#[test] +fn cli_invokes_with_matcher() { + let mut cmd = Command::cargo_bin("qk").unwrap(); + cmd.arg("no-such-pod").assert().failure(); +} \ No newline at end of file