From 0ff1001a9d878e4dfde348808b9fb05786f8b8b7 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 01:00:53 +0900 Subject: [PATCH 1/3] fix(kubectl): passthrough named get before list-only JSON formatter (#4613) --- src/cmds/cloud/container.rs | 75 ++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index d160c4ad29..39e89fb1b5 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -839,7 +839,36 @@ fn k8s_get_requests_raw_output(args: &[String]) -> bool { "-o" | "--output" | "-w" | "--watch" | "--show-labels" | "--show-kind" ) || arg.starts_with("-o") || arg.starts_with("--output=") - }) + }) || k8s_get_has_named_resource(args) +} + +/// `kubectl get pods NAME` / `get services NAME` returns a single JSON +/// object, not a List with `items`. Keep kubectl's native output for a +/// named query rather than claiming that an existing resource was not found. +/// Consume common option values so `get pods -n default` still uses the +/// compact list filter; unknown options conservatively pass through. +fn k8s_get_has_named_resource(args: &[String]) -> bool { + let mut idx = 0; + while idx < args.len() { + let flag = args[idx].as_str(); + if matches!( + flag, + "-n" | "--namespace" | "--context" | "--kubeconfig" | "--as" + | "-l" | "--selector" | "--field-selector" + ) { + idx += 2; + continue; + } + if flag == "--" { + return idx + 1 < args.len(); + } + if flag.starts_with('-') { + idx += 1; + continue; + } + return true; + } + false } pub fn run_kubectl_passthrough(args: &[OsString], verbose: u8) -> Result { @@ -1023,6 +1052,50 @@ 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_get_target_unsupported_resource() { let args = vec!["deployments".to_string()]; From 31d121e532d239ddc6a66742d907842c29aeb9d0 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 01:06:02 +0900 Subject: [PATCH 2/3] refactor(kubectl): keep named-query bypass explicit in routing --- src/cmds/cloud/container.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 39e89fb1b5..32d03c1c34 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -833,13 +833,17 @@ fn k8s_get_target(args: &[String]) -> Option<(&'static str, &[String])> { } fn k8s_get_requests_raw_output(args: &[String]) -> bool { + if k8s_get_has_named_resource(args) { + return true; + } + args.iter().any(|arg| { matches!( arg.as_str(), "-o" | "--output" | "-w" | "--watch" | "--show-labels" | "--show-kind" ) || arg.starts_with("-o") || arg.starts_with("--output=") - }) || k8s_get_has_named_resource(args) + }) } /// `kubectl get pods NAME` / `get services NAME` returns a single JSON From 5e58a70a662740a57488d8ffe1a4eb75753efaee Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <343001481+1932819331hy-debug@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:12:22 +0000 Subject: [PATCH 3/3] fix(kubectl): classify named get arguments with shared tokenizer --- src/cmds/cloud/container.rs | 82 +++++++++++---------- tests/kubectl_named_get_test.rs | 125 ++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 tests/kubectl_named_get_test.rs diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 32d03c1c34..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,48 +834,27 @@ fn k8s_get_target(args: &[String]) -> Option<(&'static str, &[String])> { } fn k8s_get_requests_raw_output(args: &[String]) -> bool { - if k8s_get_has_named_resource(args) { - return true; - } - - 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(), }) } -/// `kubectl get pods NAME` / `get services NAME` returns a single JSON -/// object, not a List with `items`. Keep kubectl's native output for a -/// named query rather than claiming that an existing resource was not found. -/// Consume common option values so `get pods -n default` still uses the -/// compact list filter; unknown options conservatively pass through. -fn k8s_get_has_named_resource(args: &[String]) -> bool { - let mut idx = 0; - while idx < args.len() { - let flag = args[idx].as_str(); - if matches!( - flag, - "-n" | "--namespace" | "--context" | "--kubeconfig" | "--as" - | "-l" | "--selector" | "--field-selector" - ) { - idx += 2; - continue; - } - if flag == "--" { - return idx + 1 < args.len(); - } - if flag.starts_with('-') { - idx += 1; - continue; - } - return true; - } - false -} - pub fn run_kubectl_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("kubectl", args, verbose) } @@ -1100,6 +1080,30 @@ api-1 | Connected to database"; } } + #[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"]); +}