Skip events for multi-object kubectl describe

Default ShowEvents=false when describing multiple objects and
the user has not explicitly set --show-events. Single-object
describe unchanged. Applied in both Run() and
DescribeMatchingResources() using a value copy of
DescriberSettings.

Signed-off-by: Mark Liu <mark@prove.com.au>
This commit is contained in:
Mark Liu
2026-02-20 08:45:54 +11:00
parent b08fa0cdf1
commit 20696e1f89
2 changed files with 427 additions and 18 deletions

View File

@@ -28,6 +28,7 @@ import (
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/cli-runtime/pkg/genericiooptions"
"k8s.io/cli-runtime/pkg/resource"
cliflag "k8s.io/component-base/cli/flag"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/describe"
"k8s.io/kubectl/pkg/util/completion"
@@ -72,11 +73,12 @@ var (
// DescribeFlags directly reflect the information that CLI is gathering via flags. They will be converted to Options,
// which reflect the runtime requirements for the command.
type DescribeFlags struct {
Factory cmdutil.Factory
Selector string
AllNamespaces bool
FilenameOptions *resource.FilenameOptions
DescriberSettings *describe.DescriberSettings
Factory cmdutil.Factory
Selector string
AllNamespaces bool
ShowEventsExplicitlySet bool
FilenameOptions *resource.FilenameOptions
DescriberSettings *describe.DescriberSettings
genericiooptions.IOStreams
}
@@ -98,7 +100,11 @@ func (flags *DescribeFlags) AddFlags(cmd *cobra.Command) {
cmdutil.AddFilenameOptionFlags(cmd, flags.FilenameOptions, "containing the resource to describe")
cmdutil.AddLabelSelectorFlagVar(cmd, &flags.Selector)
cmd.Flags().BoolVarP(&flags.AllNamespaces, "all-namespaces", "A", flags.AllNamespaces, "If present, list the requested object(s) across all namespaces. Namespace in current context is ignored even if specified with --namespace.")
cmd.Flags().BoolVar(&flags.DescriberSettings.ShowEvents, "show-events", flags.DescriberSettings.ShowEvents, "If true, display events related to the described object.")
cmd.Flags().BoolVar(&flags.DescriberSettings.ShowEvents, "show-events", flags.DescriberSettings.ShowEvents, "If true, display events related to the described object. Defaults to true for a single object, false for multiple objects and prefix matching.")
f := cmd.Flags().Lookup("show-events")
f.Value = cliflag.NewTracker(f.Value, &flags.ShowEventsExplicitlySet)
cmdutil.AddChunkSizeFlag(cmd, &flags.DescriberSettings.ChunkSize)
}
@@ -126,16 +132,17 @@ func (flags *DescribeFlags) ToOptions(parent string, args []string) (*DescribeOp
}
o := &DescribeOptions{
Selector: flags.Selector,
Namespace: namespace,
Describer: describer,
NewBuilder: flags.Factory.NewBuilder,
BuilderArgs: builderArgs,
EnforceNamespace: enforceNamespace,
AllNamespaces: flags.AllNamespaces,
FilenameOptions: flags.FilenameOptions,
DescriberSettings: flags.DescriberSettings,
IOStreams: flags.IOStreams,
Selector: flags.Selector,
Namespace: namespace,
Describer: describer,
NewBuilder: flags.Factory.NewBuilder,
BuilderArgs: builderArgs,
EnforceNamespace: enforceNamespace,
AllNamespaces: flags.AllNamespaces,
ShowEventsExplicitlySet: flags.ShowEventsExplicitlySet,
FilenameOptions: flags.FilenameOptions,
DescriberSettings: flags.DescriberSettings,
IOStreams: flags.IOStreams,
}
return o, nil
@@ -186,6 +193,9 @@ func (o *DescribeOptions) Run() error {
allErrs := []error{}
infos, err := r.Infos()
if (err != nil || len(infos) > 1) && !o.ShowEventsExplicitlySet {
o.DescriberSettings.ShowEvents = false
}
if err != nil {
if apierrors.IsNotFound(err) && len(o.BuilderArgs) == 2 {
return o.DescribeMatchingResources(err, o.BuilderArgs[0], o.BuilderArgs[1])
@@ -284,8 +294,9 @@ type DescribeOptions struct {
BuilderArgs []string
EnforceNamespace bool
AllNamespaces bool
EnforceNamespace bool
AllNamespaces bool
ShowEventsExplicitlySet bool
DescriberSettings *describe.DescriberSettings
FilenameOptions *resource.FilenameOptions

View File

@@ -22,12 +22,15 @@ import (
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/genericiooptions"
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/rest/fake"
cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/describe"
"k8s.io/kubectl/pkg/scheme"
)
@@ -325,6 +328,382 @@ func TestDescribeNoResourcesFound(t *testing.T) {
}
}
// TestDescribeShowEventsCardinality tests the cardinality-based ShowEvents
// default for the standard list and direct-name paths.
func TestDescribeShowEventsCardinality(t *testing.T) {
onePod := &corev1.PodList{
ListMeta: metav1.ListMeta{ResourceVersion: "15"},
Items: []corev1.Pod{
{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "test", ResourceVersion: "10"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
},
}
twoPods := &corev1.PodList{
ListMeta: metav1.ListMeta{ResourceVersion: "15"},
Items: []corev1.Pod{
{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "test", ResourceVersion: "10"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
{ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "test", ResourceVersion: "11"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
},
}
testCases := []struct {
name string
args []string
flags map[string]string
pods *corev1.PodList
exactName string // if set, use direct GET client instead of list Resp
expectShowEvents bool
}{
{
name: "single object keeps events",
args: []string{"pods"},
pods: onePod,
expectShowEvents: true,
},
{
name: "multiple objects defaults events off",
args: []string{"pods"},
pods: twoPods,
expectShowEvents: false,
},
{
name: "multiple objects explicit --show-events=true",
args: []string{"pods"},
pods: twoPods,
flags: map[string]string{"show-events": "true"},
expectShowEvents: true,
},
{
name: "multiple objects explicit --show-events=false",
args: []string{"pods"},
pods: twoPods,
flags: map[string]string{"show-events": "false"},
expectShowEvents: false,
},
{
name: "single object by exact name keeps events",
args: []string{"pods", "my-pod"},
exactName: "my-pod",
expectShowEvents: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
d := &testDescriber{Output: "test output"}
oldFn := describe.DescriberFn
defer func() { describe.DescriberFn = oldFn }()
describe.DescriberFn = d.describerFor
tf := cmdtesting.NewTestFactory().WithNamespace("test")
defer tf.Cleanup()
codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...)
if tc.exactName != "" {
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: tc.exactName, Namespace: "test", ResourceVersion: "10"},
Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways},
}
tf.UnstructuredClient = &fake.RESTClient{
NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
if req.URL.Path == "/namespaces/test/pods/"+tc.exactName && req.Method == http.MethodGet {
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, pod)}, nil
}
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path)
return nil, nil
}),
}
} else {
tf.UnstructuredClient = &fake.RESTClient{
NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
Resp: &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, tc.pods)},
}
}
cmd := NewCmdDescribe("kubectl", tf, genericiooptions.NewTestIOStreamsDiscard())
for k, v := range tc.flags {
_ = cmd.Flags().Set(k, v)
}
cmd.Run(cmd, tc.args)
if d.Settings.ShowEvents != tc.expectShowEvents {
t.Errorf("expected ShowEvents=%v, got %v", tc.expectShowEvents, d.Settings.ShowEvents)
}
})
}
}
// TestDescribeShowEventsPrefixPath tests the cardinality-based ShowEvents
// default for the prefix-match path (kubectl describe TYPE NAME_PREFIX).
func TestDescribeShowEventsPrefixPath(t *testing.T) {
twoFooOneBar := &corev1.PodList{
ListMeta: metav1.ListMeta{ResourceVersion: "15"},
Items: []corev1.Pod{
{ObjectMeta: metav1.ObjectMeta{Name: "foo-abc", Namespace: "test", ResourceVersion: "10"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
{ObjectMeta: metav1.ObjectMeta{Name: "foo-def", Namespace: "test", ResourceVersion: "11"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
{ObjectMeta: metav1.ObjectMeta{Name: "bar-xyz", Namespace: "test", ResourceVersion: "12"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
},
}
twoPodsFoo := &corev1.PodList{
ListMeta: metav1.ListMeta{ResourceVersion: "15"},
Items: []corev1.Pod{
{ObjectMeta: metav1.ObjectMeta{Name: "foo-abc", Namespace: "test", ResourceVersion: "10"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
{ObjectMeta: metav1.ObjectMeta{Name: "foo-def", Namespace: "test", ResourceVersion: "11"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
},
}
oneFooOneBar := &corev1.PodList{
ListMeta: metav1.ListMeta{ResourceVersion: "15"},
Items: []corev1.Pod{
{ObjectMeta: metav1.ObjectMeta{Name: "foo-abc", Namespace: "test", ResourceVersion: "10"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
{ObjectMeta: metav1.ObjectMeta{Name: "bar-xyz", Namespace: "test", ResourceVersion: "11"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
},
}
barOnly := &corev1.PodList{
ListMeta: metav1.ListMeta{ResourceVersion: "15"},
Items: []corev1.Pod{
{ObjectMeta: metav1.ObjectMeta{Name: "bar-abc", Namespace: "test", ResourceVersion: "10"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
},
}
testCases := []struct {
name string
pods *corev1.PodList
flags map[string]string
expectShowEvents bool
expectMatchCount int // 0 when expecting error
expectFatal bool
}{
{
name: "multi match defaults events off",
pods: twoFooOneBar,
expectShowEvents: false,
expectMatchCount: 2,
},
{
name: "single match still disables events (prefix path)",
pods: oneFooOneBar,
expectShowEvents: false,
expectMatchCount: 1,
},
{
name: "multi match explicit --show-events=true",
pods: twoPodsFoo,
flags: map[string]string{"show-events": "true"},
expectShowEvents: true,
expectMatchCount: 2,
},
{
name: "multi match explicit --show-events=false",
pods: twoPodsFoo,
flags: map[string]string{"show-events": "false"},
expectShowEvents: false,
expectMatchCount: 2,
},
{
name: "no match returns original error",
pods: barOnly,
expectFatal: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
d := &testDescriber{Output: "test output"}
oldFn := describe.DescriberFn
defer func() { describe.DescriberFn = oldFn }()
describe.DescriberFn = d.describerFor
tf := cmdtesting.NewTestFactory().WithNamespace("test")
defer tf.Cleanup()
codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...)
tf.UnstructuredClient = &fake.RESTClient{
NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
switch p, m := req.URL.Path, req.Method; {
case p == "/namespaces/test/pods/foo" && m == "GET":
return &http.Response{StatusCode: http.StatusNotFound, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, nil
case p == "/namespaces/test/pods" && m == "GET":
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, tc.pods)}, nil
default:
return &http.Response{StatusCode: http.StatusNotFound, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, nil
}
}),
}
var fatalMsg string
cmdutil.BehaviorOnFatal(func(msg string, code int) { fatalMsg = msg })
defer cmdutil.DefaultBehaviorOnFatal()
streams, _, buf, _ := genericiooptions.NewTestIOStreams()
cmd := NewCmdDescribe("kubectl", tf, streams)
for k, v := range tc.flags {
_ = cmd.Flags().Set(k, v)
}
cmd.Run(cmd, []string{"pods", "foo"})
if tc.expectFatal {
if buf.Len() != 0 {
t.Errorf("expected no output, got: %q", buf.String())
}
if fatalMsg == "" {
t.Error("expected fatal error, got none")
}
return
}
count := strings.Count(buf.String(), d.Output)
if count != tc.expectMatchCount {
t.Errorf("expected %d describe outputs, got %d", tc.expectMatchCount, count)
}
if d.Settings.ShowEvents != tc.expectShowEvents {
t.Errorf("expected ShowEvents=%v, got %v", tc.expectShowEvents, d.Settings.ShowEvents)
}
})
}
}
// TestDescribePartialErrorStillDisablesEvents verifies that when ContinueOnError
// produces partial results, the cardinality check applies to successful infos.
func TestDescribePartialErrorStillDisablesEvents(t *testing.T) {
d := &testDescriber{Output: "test output"}
oldFn := describe.DescriberFn
defer func() { describe.DescriberFn = oldFn }()
describe.DescriberFn = d.describerFor
foo := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "test", ResourceVersion: "10"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}}
baz := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}}
tf := cmdtesting.NewTestFactory().WithNamespace("test")
defer tf.Cleanup()
codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...)
tf.UnstructuredClient = &fake.RESTClient{
NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
switch p, m := req.URL.Path, req.Method; {
case p == "/namespaces/test/pods/foo" && m == "GET":
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, foo)}, nil
case p == "/namespaces/test/pods/bar" && m == "GET":
return &http.Response{StatusCode: http.StatusInternalServerError, Header: cmdtesting.DefaultHeader(),
Body: cmdtesting.StringBody(`{"kind":"Status","apiVersion":"v1","status":"Failure","message":"internal error"}`)}, nil
case p == "/namespaces/test/pods/baz" && m == "GET":
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, baz)}, nil
default:
return &http.Response{StatusCode: http.StatusNotFound, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, nil
}
}),
}
var fatalMsg string
cmdutil.BehaviorOnFatal(func(msg string, code int) { fatalMsg = msg })
defer cmdutil.DefaultBehaviorOnFatal()
streams, _, buf, _ := genericiooptions.NewTestIOStreams()
cmd := NewCmdDescribe("kubectl", tf, streams)
cmd.Run(cmd, []string{"pods", "foo", "bar", "baz"})
if count := strings.Count(buf.String(), d.Output); count != 2 {
t.Errorf("expected 2 describe outputs for partial success, got %d", count)
}
if d.Settings.ShowEvents != false {
t.Errorf("expected ShowEvents=false for 2 successful infos, got %v", d.Settings.ShowEvents)
}
if fatalMsg == "" {
t.Error("expected partial error to be reported")
}
}
// TestDescribeMixedResourceTypesDisablesEvents verifies that the cardinality
// check applies across resource types (1 pod + 1 service = events off).
func TestDescribeMixedResourceTypesDisablesEvents(t *testing.T) {
d := &testDescriber{Output: "test output"}
oldFn := describe.DescriberFn
defer func() { describe.DescriberFn = oldFn }()
describe.DescriberFn = d.describerFor
pods := &corev1.PodList{
ListMeta: metav1.ListMeta{ResourceVersion: "15"},
Items: []corev1.Pod{{ObjectMeta: metav1.ObjectMeta{Name: "my-pod", Namespace: "test", ResourceVersion: "10"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}}},
}
services := &corev1.ServiceList{
ListMeta: metav1.ListMeta{ResourceVersion: "16"},
Items: []corev1.Service{{ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "test", ResourceVersion: "11"}, Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeClusterIP}}},
}
tf := cmdtesting.NewTestFactory().WithNamespace("test")
defer tf.Cleanup()
codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...)
tf.UnstructuredClient = &fake.RESTClient{
NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
switch p, m := req.URL.Path, req.Method; {
case p == "/namespaces/test/pods" && m == "GET":
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, pods)}, nil
case p == "/namespaces/test/services" && m == "GET":
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, services)}, nil
default:
return &http.Response{StatusCode: http.StatusNotFound, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, nil
}
}),
}
cmd := NewCmdDescribe("kubectl", tf, genericiooptions.NewTestIOStreamsDiscard())
cmd.Run(cmd, []string{"pods,services"})
if d.Settings.ShowEvents != false {
t.Errorf("expected ShowEvents=false for mixed types (2 objects), got %v", d.Settings.ShowEvents)
}
}
// TestDescribeDescriberErrorsRetainDisabledEvents verifies that when 3 objects
// are fetched (events disabled) but the describer fails for 2, the successful
// describe still receives ShowEvents=false.
func TestDescribeDescriberErrorsRetainDisabledEvents(t *testing.T) {
cd := &conditionalDescriber{
Output: "test output",
FailNames: map[string]bool{"foo": true, "bar": true},
}
oldFn := describe.DescriberFn
defer func() { describe.DescriberFn = oldFn }()
describe.DescriberFn = cd.describerFor
pods := &corev1.PodList{
ListMeta: metav1.ListMeta{ResourceVersion: "15"},
Items: []corev1.Pod{
{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "test", ResourceVersion: "10"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
{ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "test", ResourceVersion: "11"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
{ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, Spec: corev1.PodSpec{RestartPolicy: corev1.RestartPolicyAlways}},
},
}
tf := cmdtesting.NewTestFactory().WithNamespace("test")
defer tf.Cleanup()
codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...)
tf.UnstructuredClient = &fake.RESTClient{
NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
Resp: &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, pods)},
}
var fatalMsg string
cmdutil.BehaviorOnFatal(func(msg string, code int) { fatalMsg = msg })
defer cmdutil.DefaultBehaviorOnFatal()
streams, _, buf, _ := genericiooptions.NewTestIOStreams()
cmd := NewCmdDescribe("kubectl", tf, streams)
cmd.Run(cmd, []string{"pods"})
if !strings.Contains(buf.String(), cd.Output) {
t.Errorf("expected baz's output in result, got: %q", buf.String())
}
if cd.LastSettings.ShowEvents != false {
t.Errorf("expected ShowEvents=false for 3 infos, got %v", cd.LastSettings.ShowEvents)
}
if fatalMsg == "" {
t.Error("expected describer errors to be reported")
}
}
type testDescriber struct {
Name, Namespace string
Settings describe.DescriberSettings
@@ -340,3 +719,22 @@ func (t *testDescriber) Describe(namespace, name string, describerSettings descr
func (t *testDescriber) describerFor(restClientGetter genericclioptions.RESTClientGetter, mapping *meta.RESTMapping) (describe.ResourceDescriber, error) {
return t, nil
}
// conditionalDescriber fails Describe() for names in FailNames, succeeds for others.
type conditionalDescriber struct {
FailNames map[string]bool
Output string
LastSettings describe.DescriberSettings
}
func (c *conditionalDescriber) Describe(namespace, name string, settings describe.DescriberSettings) (string, error) {
c.LastSettings = settings
if c.FailNames[name] {
return "", fmt.Errorf("describe failed for %s", name)
}
return c.Output, nil
}
func (c *conditionalDescriber) describerFor(restClientGetter genericclioptions.RESTClientGetter, mapping *meta.RESTMapping) (describe.ResourceDescriber, error) {
return c, nil
}