diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index d160c4ad29..1ec0e28f3f 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -1,5 +1,6 @@ //! Filters Docker and kubectl output into compact summaries. +use crate::core::arg_tokenizer::{Dialect, TokenKind, ValueSpec, tokenize_grammar}; use crate::core::guard::never_worse; use crate::core::runner::{self, RunOptions}; use crate::core::shell::display_args; @@ -833,12 +834,24 @@ fn k8s_get_target(args: &[String]) -> Option<(&'static str, &[String])> { } fn k8s_get_requests_raw_output(args: &[String]) -> bool { - args.iter().any(|arg| { - matches!( - arg.as_str(), - "-o" | "--output" | "-w" | "--watch" | "--show-labels" | "--show-kind" - ) || arg.starts_with("-o") - || arg.starts_with("--output=") + let takes_value = |kind: TokenKind, name: &str| match (kind, name) { + (TokenKind::Short, "n" | "l") + | ( + TokenKind::Long, + "namespace" | "context" | "kubeconfig" | "as" | "selector" | "field-selector", + ) => Some(ValueSpec::value().claiming_dash_dash()), + _ => None, + }; + let tokens = tokenize_grammar(args, &takes_value, Dialect::Posix); + tokens.iter().any(|token| match token.kind { + // Named get returns a Pod/Service, not the List the compact filter expects. + TokenKind::Positional => token.is_free_positional(), + TokenKind::DashDash => false, + TokenKind::Short if token.text == "A" => false, + TokenKind::Long if token.text == "all-namespaces" => false, + // Preserve native output/diagnostics for output flags, unknown options, + // and missing values rather than guessing their argument grammar. + kind => takes_value(kind, token.text).is_none() || token.value(&tokens).is_none(), }) } @@ -1023,6 +1036,74 @@ api-1 | Connected to database"; } } + // #4613: named get returns a Pod/Service JSON object rather than a List. + // Never run that result through the list-only `items` formatter. + #[test] + fn test_k8s_get_named_pod_and_service_pass_through() { + for (resource, name) in [ + ("pods", "web-5dc4d46d9f-hsrcf"), + ("po", "web-5dc4d46d9f-hsrcf"), + ("services", "api"), + ("svc", "api"), + ] { + let args = vec![resource.to_string(), name.to_string()]; + assert_eq!(k8s_get_target(&args), None, "{resource} {name}"); + } + } + + #[test] + fn test_k8s_named_get_with_namespace_or_context_passes_through() { + for args in [ + vec!["pods", "-n", "production", "web"], + vec!["pods", "--namespace", "production", "web"], + vec!["services", "--context", "prod", "api"], + vec!["pods", "-nproduction", "web"], + vec!["pods", "--namespace=production", "web"], + ] { + let args: Vec = args.into_iter().map(str::to_string).collect(); + assert_eq!(k8s_get_target(&args), None, "{args:?}"); + } + } + + #[test] + fn test_k8s_list_with_option_values_still_filters() { + for args in [ + vec!["pods", "-n", "default"], + vec!["services", "--context", "prod"], + vec!["pods", "-l", "app=api"], + vec!["pods", "--field-selector", "status.phase=Running"], + vec!["pods", "--namespace=default"], + vec!["pods", "-A"], + ] { + let args: Vec = args.into_iter().map(str::to_string).collect(); + assert!(k8s_get_target(&args).is_some(), "{args:?}"); + } + } + + #[test] + fn test_k8s_list_flag_clusters_and_flag_like_values_still_filter() { + for args in [ + vec!["pods", "-An", "default"], + vec!["pods", "--context", "-o"], + vec!["pods", "--namespace", "--"], + ] { + let args: Vec = args.into_iter().map(str::to_string).collect(); + assert!(k8s_get_target(&args).is_some(), "{args:?}"); + } + } + + #[test] + fn test_k8s_get_unknown_or_incomplete_options_pass_through() { + for args in [ + vec!["pods", "--new-option=value"], + vec!["pods", "-n"], + vec!["pods", "--context"], + ] { + let args: Vec = args.into_iter().map(str::to_string).collect(); + assert_eq!(k8s_get_target(&args), None, "{args:?}"); + } + } + #[test] fn test_k8s_get_target_unsupported_resource() { let args = vec!["deployments".to_string()]; diff --git a/tests/kubectl_named_get_test.rs b/tests/kubectl_named_get_test.rs new file mode 100644 index 0000000000..d9d32b58c8 --- /dev/null +++ b/tests/kubectl_named_get_test.rs @@ -0,0 +1,125 @@ +//! A named get must keep kubectl's native result instead of applying a List filter. +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Output; + +mod common; + +fn run_get(args: &[&str], body: &str) -> (Output, Vec) { + let dir = common::tempdir(); + let tool = dir.path().join("kubectl"); + let argv = dir.path().join("argv"); + fs::write( + &tool, + format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$RTK_TEST_ARGV\"\n{body}\n"), + ) + .expect("write kubectl"); + fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).expect("chmod kubectl"); + let out = common::rtk_command() + .args(["kubectl", "get"]) + .args(args) + .env( + "PATH", + format!( + "{}:{}", + dir.path().display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .env("RTK_TEST_ARGV", &argv) + .output() + .expect("run rtk kubectl get"); + let forwarded = fs::read_to_string(argv) + .expect("kubectl was invoked") + .lines() + .map(str::to_owned) + .collect(); + (out, forwarded) +} + +#[test] +fn named_pods_keep_native_output_and_arguments() { + let native = "NAME READY STATUS RESTARTS AGE\nweb 1/1 Running 0 1d\n"; + let body = format!( + r#"for arg do + if [ "$arg" = json ]; then + printf '%s\n' '{{"kind":"Pod","metadata":{{"name":"web"}},"status":{{"phase":"Running"}}}}' + exit 0 + fi +done +printf '{native}'"# + ); + for resource in ["pod", "pods", "po"] { + let args = [resource, "-n", "production", "web"]; + let (out, forwarded) = run_get(&args, &body); + assert!(out.status.success(), "{out:?}"); + assert_eq!(out.stdout, native.as_bytes(), "{resource}"); + assert!(out.stderr.is_empty(), "{out:?}"); + assert_eq!(forwarded, ["get", resource, "-n", "production", "web"]); + } +} + +#[test] +fn named_services_keep_native_output_and_arguments() { + let native = "NAME TYPE CLUSTER-IP PORT(S) AGE\napi ClusterIP 10.0.0.1 443/TCP 1d\n"; + let body = format!( + r#"for arg do + if [ "$arg" = json ]; then + printf '%s\n' '{{"kind":"Service","metadata":{{"name":"api"}},"spec":{{"type":"ClusterIP"}}}}' + exit 0 + fi +done +printf '{native}'"# + ); + for resource in ["service", "services", "svc"] { + let args = [resource, "api", "--context=prod"]; + let (out, forwarded) = run_get(&args, &body); + assert!(out.status.success(), "{out:?}"); + assert_eq!(out.stdout, native.as_bytes(), "{resource}"); + assert!(out.stderr.is_empty(), "{out:?}"); + assert_eq!(forwarded, ["get", resource, "api", "--context=prod"]); + } +} + +#[test] +fn named_get_preserves_failure_streams_and_exit_status() { + let (out, forwarded) = run_get( + &["pods", "missing"], + "printf 'partial output'; printf 'Error from server (NotFound)' >&2; exit 7", + ); + assert_eq!(out.status.code(), Some(7)); + assert_eq!(out.stdout, b"partial output"); + assert_eq!(out.stderr, b"Error from server (NotFound)"); + assert_eq!(forwarded, ["get", "pods", "missing"]); +} + +#[test] +fn names_after_separator_and_multiple_names_stay_native() { + for args in [ + vec!["pods", "--", "web"], + vec!["pods", "web", "api"], + vec!["services", "api", "-o", "yaml"], + ] { + let (out, forwarded) = run_get(&args, "printf 'native result\\n'"); + assert!(out.status.success(), "{out:?}"); + assert_eq!(out.stdout, b"native result\n"); + let expected: Vec<&str> = std::iter::once("get").chain(args).collect(); + assert_eq!(forwarded, expected); + } +} + +#[test] +fn pod_lists_with_namespace_still_use_the_list_filter() { + let fixture = include_str!("fixtures/oc_pods.json"); + let body = format!("cat <<'RTK_PODS'\n{fixture}\nRTK_PODS"); + let (out, forwarded) = run_get(&["pods", "-n", "default"], &body); + assert!(out.status.success(), "{out:?}"); + assert!(out.stderr.is_empty(), "{out:?}"); + let shown = String::from_utf8(out.stdout).expect("UTF-8 output"); + assert!(shown.contains("pods:"), "{shown}"); + assert!(!shown.contains("No pods found"), "{shown}"); + assert!(shown.len() < fixture.len(), "list should remain compact"); + assert_eq!(forwarded, ["get", "pods", "-o", "json", "-n", "default"]); +}