diff --git a/cmd/kubectl-ate/README.md b/cmd/kubectl-ate/README.md index 61f75324a..061cc965b 100644 --- a/cmd/kubectl-ate/README.md +++ b/cmd/kubectl-ate/README.md @@ -26,6 +26,8 @@ By default, `kubectl-ate` will automatically read your `~/.kube/config`, discove If you prefer to route traffic directly (e.g., through a LoadBalancer or when running natively inside a cluster pod), simply provide the `--endpoint` flag to bypass the tunnel. +Because the tunnel is an implementation detail, diagnostics that the Kubernetes client reports about it are not printed. Pass `--verbose` to see them when troubleshooting a connection. + ## Tracing The CLI supports on-demand tracing using the `--trace` flag. When enabled, the CLI will generate a trace ID and signal to the server that it wants the request to be traced. @@ -67,6 +69,7 @@ These flags can be appended to any command: | `--endpoint` | | Manual gRPC endpoint override (e.g., `localhost:8080`) | | | `--output` | `-o` | Output format (`table`, `json`, `yaml`) | `table` | | `--trace` | | Enable on-demand tracing for the request | `false` | +| `--verbose` | | Print internal Kubernetes client diagnostics that are otherwise suppressed | `false` | --- diff --git a/cmd/kubectl-ate/internal/cmd/clientlogging.go b/cmd/kubectl-ate/internal/cmd/clientlogging.go new file mode 100644 index 000000000..116d27ebf --- /dev/null +++ b/cmd/kubectl-ate/internal/cmd/clientlogging.go @@ -0,0 +1,51 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "fmt" + "io" + + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +// configureClientLogging keeps client-go's unhandled-error reports out of the +// user's terminal unless --verbose asks for them. +// +// client-go reports problems it cannot return to a caller through +// utilruntime.HandleError, whose default handler writes to stderr naming the +// client-go file that failed, for example: +// +// E0520 15:40:33.154830 3507355 portforward.go:502] "Unhandled Error" err="..." +// +// Someone running `kubectl ate create actor` cannot act on that, and the +// port-forwarding it refers to is an implementation detail of this plugin. +// +// klog is deliberately left alone. client-go logs warnings a user can act on +// directly through it, such as a missing kubeconfig or a credential plugin that +// failed to refresh, and those should keep reaching the terminal. Replacing +// ErrorHandlers also drops upstream's 1ms rate limiter, which existed only +// because the default handler writes to stderr. +func configureClientLogging(errOut io.Writer, verbose bool) { + utilruntime.ErrorHandlers = []utilruntime.ErrorHandler{ + func(_ context.Context, err error, msg string, keysAndValues ...any) { + if !verbose { + return + } + fmt.Fprintf(errOut, "kubectl-ate: %s\n", utilruntime.ErrorToString(err, msg, keysAndValues...)) + }, + } +} diff --git a/cmd/kubectl-ate/internal/cmd/clientlogging_test.go b/cmd/kubectl-ate/internal/cmd/clientlogging_test.go new file mode 100644 index 000000000..f20f96c21 --- /dev/null +++ b/cmd/kubectl-ate/internal/cmd/clientlogging_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "bytes" + "errors" + "strings" + "testing" + + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +// errPortForwardReset reproduces the client-go report from +// https://github.com/agent-substrate/substrate/issues/26, which surfaced after a +// successful command. +var errPortForwardReset = errors.New("error copying from local connection to remote stream: " + + "read tcp4 127.0.0.1:42521->127.0.0.1:42648: read: connection reset by peer") + +func restoreClientLogging(t *testing.T) { + t.Helper() + handlers := utilruntime.ErrorHandlers + t.Cleanup(func() { utilruntime.ErrorHandlers = handlers }) +} + +func TestConfigureClientLogging(t *testing.T) { + tests := []struct { + name string + verbose bool + want string + }{ + {name: "quiet by default"}, + {name: "verbose", verbose: true, want: "connection reset by peer"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + restoreClientLogging(t) + + var errOut bytes.Buffer + configureClientLogging(&errOut, test.verbose) + utilruntime.HandleError(errPortForwardReset) + + got := errOut.String() + if test.want == "" { + if got != "" { + t.Errorf("stderr = %q, want no output", got) + } + return + } + if !strings.Contains(got, test.want) { + t.Errorf("stderr = %q, want it to contain %q", got, test.want) + } + }) + } +} + +func TestRootCommandConfiguresClientLogging(t *testing.T) { + restoreClientLogging(t) + + verbose = false + t.Cleanup(func() { verbose = false }) + + var errOut bytes.Buffer + rootCmd.SetErr(&errOut) + t.Cleanup(func() { rootCmd.SetErr(nil) }) + + if err := rootCmd.PersistentPreRunE(rootCmd, nil); err != nil { + t.Fatalf("PersistentPreRunE: %v", err) + } + utilruntime.HandleError(errPortForwardReset) + + if errOut.Len() != 0 { + t.Errorf("stderr = %q, want no output", errOut.String()) + } +} diff --git a/cmd/kubectl-ate/internal/cmd/root.go b/cmd/kubectl-ate/internal/cmd/root.go index a62f6215a..a89594df3 100644 --- a/cmd/kubectl-ate/internal/cmd/root.go +++ b/cmd/kubectl-ate/internal/cmd/root.go @@ -29,6 +29,7 @@ var ( endpoint string outputFmt string traceEnabled bool + verbose bool ) var rootCmd = &cobra.Command{ @@ -41,6 +42,7 @@ var rootCmd = &cobra.Command{ if outputFmt != "table" && outputFmt != "json" && outputFmt != "yaml" { return fmt.Errorf("invalid output format %q. Must be one of: table, json, yaml", outputFmt) } + configureClientLogging(cmd.ErrOrStderr(), verbose) return nil }, } @@ -58,4 +60,5 @@ func init() { rootCmd.PersistentFlags().StringVar(&endpoint, "endpoint", "", "Manual override for the gRPC target (e.g., localhost:8080). If omitted, automatically port-forwards.") rootCmd.PersistentFlags().StringVarP(&outputFmt, "output", "o", "table", "Output format. One of: table|json|yaml") rootCmd.PersistentFlags().BoolVar(&traceEnabled, "trace", false, "Enable tracing for the request") + rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Print internal Kubernetes client diagnostics that are otherwise suppressed") }