Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 87 additions & 6 deletions src/cmds/cloud/container.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(),
})
}

Expand Down Expand Up @@ -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<String> = 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<String> = 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<String> = 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<String> = 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()];
Expand Down
125 changes: 125 additions & 0 deletions tests/kubectl_named_get_test.rs
Original file line number Diff line number Diff line change
@@ -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<String>) {
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"]);
}
Loading