From ee02a8ab32b2f25c034d76464b3909fd59a132b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=86=9B10092085?= Date: Fri, 31 Oct 2025 14:57:46 +0800 Subject: [PATCH] Add --detach-keys for kubectl attach command --- .../k8s.io/kubectl/pkg/cmd/attach/attach.go | 22 ++- .../kubectl/pkg/cmd/attach/attach_test.go | 4 +- staging/src/k8s.io/kubectl/pkg/cmd/run/run.go | 6 + .../src/k8s.io/kubectl/pkg/util/term/term.go | 25 ++++ .../k8s.io/kubectl/pkg/util/term/term_test.go | 126 ++++++++++++++++++ 5 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 staging/src/k8s.io/kubectl/pkg/util/term/term_test.go diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/attach/attach.go b/staging/src/k8s.io/kubectl/pkg/cmd/attach/attach.go index 17fa740ee16..147aeb2cf60 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/attach/attach.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/attach/attach.go @@ -66,6 +66,8 @@ var ( const ( defaultPodAttachTimeout = 60 * time.Second defaultPodLogsTimeout = 20 * time.Second + + defaultDetachSequence = "ctrl-p,ctrl-q" ) // AttachOptions declare the arguments accepted by the Attach command @@ -75,6 +77,8 @@ type AttachOptions struct { // whether to disable use of standard error when streaming output from tty DisableStderr bool + DetachKeys string + CommandName string Pod *corev1.Pod @@ -122,6 +126,7 @@ func NewCmdAttach(f cmdutil.Factory, streams genericiooptions.IOStreams) *cobra. cmd.Flags().BoolVarP(&o.Stdin, "stdin", "i", o.Stdin, "Pass stdin to the container") cmd.Flags().BoolVarP(&o.TTY, "tty", "t", o.TTY, "Stdin is a TTY") cmd.Flags().BoolVarP(&o.Quiet, "quiet", "q", o.Quiet, "Only print output from the remote session") + cmd.Flags().StringVar(&o.DetachKeys, "detach-keys", defaultDetachSequence, "Override the key sequence for detaching a container") return cmd } @@ -149,8 +154,17 @@ func DefaultAttachFunc(o *AttachOptions, containerToAttach *corev1.Container, ra Stderr: !o.DisableStderr, TTY: raw, }, scheme.ParameterCodec) - - return o.Attach.Attach(req.URL(), o.Config, o.In, o.Out, o.ErrOut, raw, sizeQueue) + stdin := o.In + if o.Stdin && raw { + if o.DetachKeys == "" { + o.DetachKeys = defaultDetachSequence + } + stdin, err = term.NewDetachableReader(stdin, o.DetachKeys) + if err != nil { + return fmt.Errorf("could not bind detach keys: %w", err) + } + } + return o.Attach.Attach(req.URL(), o.Config, stdin, o.Out, o.ErrOut, raw, sizeQueue) } } @@ -352,13 +366,13 @@ func (o *AttachOptions) GetContainerName(pod *corev1.Pod) (string, error) { // reattachMessage returns a message to print after attach has completed, or // the empty string if no message should be printed. func (o *AttachOptions) reattachMessage(containerName string, rawTTY bool) string { - if o.Quiet || !o.Stdin || !rawTTY || o.Pod.Spec.RestartPolicy != corev1.RestartPolicyAlways { + if o.Quiet || !o.Stdin || !rawTTY { return "" } if _, path := podcmd.FindContainerByName(o.Pod, containerName); strings.HasPrefix(path, "spec.ephemeralContainers") { return fmt.Sprintf("Session ended, the ephemeral container will not be restarted but may be reattached using '%s %s -c %s -n %s -i -t' if it is still running", o.CommandName, o.Pod.Name, containerName, o.Pod.Namespace) } - return fmt.Sprintf("Session ended, resume using '%s %s -c %s -n %s -i -t' command when the pod is running", o.CommandName, o.Pod.Name, containerName, o.Pod.Namespace) + return fmt.Sprintf("Session ended, resume using '%s %s -c %s -n %s -i -t' command", o.CommandName, o.Pod.Name, containerName, o.Pod.Namespace) } type terminalSizeQueueAdapter struct { diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/attach/attach_test.go b/staging/src/k8s.io/kubectl/pkg/cmd/attach/attach_test.go index 4c3a27a34be..1565b73a4ea 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/attach/attach_test.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/attach/attach_test.go @@ -510,7 +510,7 @@ func TestReattachMessage(t *testing.T) { container: "bar", rawTTY: true, stdin: true, - expected: "Session ended, resume using 'kubectl foo -c bar -n test -i -t' command when the pod is running", + expected: "Session ended, resume using 'kubectl foo -c bar -n test -i -t' command", }, { name: "no stdin", @@ -541,7 +541,7 @@ func TestReattachMessage(t *testing.T) { container: "bar", rawTTY: true, stdin: true, - expected: "", + expected: "Session ended, resume using 'kubectl foo -c bar -n test -i -t' command", }, { name: "ephemeral container", diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/run/run.go b/staging/src/k8s.io/kubectl/pkg/cmd/run/run.go index 77ca65cbe78..701fd2a281a 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/run/run.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/run/run.go @@ -92,6 +92,8 @@ var ( const ( defaultPodAttachTimeout = 60 * time.Second + + defaultDetachSequence = "ctrl-p,ctrl-q" ) var metadataAccessor = meta.NewAccessor() @@ -117,6 +119,7 @@ type RunOptions struct { ArgsLenAtDash int Attach bool + DetachKeys string Expose bool Image string Interactive bool @@ -190,6 +193,7 @@ func addRunFlags(cmd *cobra.Command, opt *RunOptions) { cmd.Flags().BoolVar(&opt.Expose, "expose", opt.Expose, "If true, create a ClusterIP service associated with the pod. Requires `--port`.") cmd.Flags().BoolVarP(&opt.Quiet, "quiet", "q", opt.Quiet, "If true, suppress prompt messages.") cmd.Flags().BoolVar(&opt.Privileged, "privileged", opt.Privileged, i18n.T("If true, run the container in privileged mode.")) + cmd.Flags().StringVar(&opt.DetachKeys, "detach-keys", defaultDetachSequence, "Override the key sequence for detaching a container.") cmdutil.AddFieldManagerFlagVar(cmd, &opt.fieldManager, "kubectl-run") opt.AddOverrideFlags(cmd) } @@ -340,6 +344,8 @@ func (o *RunOptions) Run(f cmdutil.Factory, cmd *cobra.Command, args []string) e CommandName: cmd.Parent().CommandPath() + " attach", Attach: &attach.DefaultRemoteAttach{}, + + DetachKeys: o.DetachKeys, } config, err := f.ToRESTConfig() if err != nil { diff --git a/staging/src/k8s.io/kubectl/pkg/util/term/term.go b/staging/src/k8s.io/kubectl/pkg/util/term/term.go index 93a992fe31e..895a8aa2af9 100644 --- a/staging/src/k8s.io/kubectl/pkg/util/term/term.go +++ b/staging/src/k8s.io/kubectl/pkg/util/term/term.go @@ -17,6 +17,7 @@ limitations under the License. package term import ( + "errors" "io" "os" @@ -113,3 +114,27 @@ func (t TTY) Safe(fn SafeFunc) error { term.RestoreTerminal(inFd, state) }).Run(fn) } + +type detachableReader struct { + escapeProxy io.Reader +} + +func NewDetachableReader(r io.Reader, detachKeys string) (io.Reader, error) { + detachKeyBytes, err := term.ToBytes(detachKeys) + if err != nil { + return nil, err + } + return &detachableReader{ + escapeProxy: term.NewEscapeProxy(r, detachKeyBytes), + }, nil +} + +func (r *detachableReader) Read(p []byte) (n int, err error) { + n, err = r.escapeProxy.Read(p) + if errors.Is(err, term.EscapeError{}) { + // EscapeError is expected when the user types the detach key sequence, so we should not treat it as an error. + // Instead, we return io.EOF so that the attach session will end gracefully. + err = io.EOF + } + return n, err +} diff --git a/staging/src/k8s.io/kubectl/pkg/util/term/term_test.go b/staging/src/k8s.io/kubectl/pkg/util/term/term_test.go new file mode 100644 index 00000000000..f5832618281 --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/util/term/term_test.go @@ -0,0 +1,126 @@ +/* +Copyright The Kubernetes Authors. + +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 term + +import ( + "errors" + "io" + "strings" + "testing" +) + +// errorReader is a helper that always returns a fixed error. +type errorReader struct { + err error +} + +func (e *errorReader) Read(p []byte) (n int, err error) { + return 0, e.err +} + +func TestDetachableReader(t *testing.T) { + tests := []struct { + name string + detachKeys string + input string + bufSize int + wantN int + wantErr error + wantData string + }{ + { + name: "normal read without detach sequence", + detachKeys: "ctrl-p,ctrl-q", + input: "hello world this is a test", + bufSize: 20, + wantN: 20, + wantErr: nil, + wantData: "hello world this is ", + }, + { + name: "detach sequence triggers EOF", + detachKeys: "ctrl-p,ctrl-q", + input: "data before\x10\x11data after", + bufSize: 30, + wantN: 11, + wantErr: io.EOF, + wantData: "data before", + }, + { + name: "partial detach sequence is treated as normal data", + detachKeys: "ctrl-p,ctrl-q", + input: "normal text\x10only ctrl-p no q", + bufSize: 30, + wantN: 28, + wantErr: nil, + wantData: "normal text\x10only ctrl-p no q", + }, + { + name: "underlying error is passed through unchanged", + detachKeys: "ctrl-a", + input: "", // dummy, error comes from mock + bufSize: 10, + wantN: 0, + wantErr: errors.New("simulated io error"), + wantData: "", + }, + { + name: "detach sequence exactly at buffer boundary", + detachKeys: "ctrl-a", + input: "short\x01rest", + bufSize: 6, + wantN: 5, + wantErr: io.EOF, + wantData: "short", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var r io.Reader + var err error + + if tt.wantErr != nil && !errors.Is(tt.wantErr, io.EOF) { + // Special case: simulate underlying read error + r = &errorReader{err: tt.wantErr} + } else { + r, err = NewDetachableReader(strings.NewReader(tt.input), tt.detachKeys) + if err != nil { + t.Fatalf("NewDetachableReader failed: %v", err) + } + } + + buf := make([]byte, tt.bufSize) + n, readErr := r.Read(buf) + + if n != tt.wantN { + t.Errorf("Read() got n = %d, want %d", n, tt.wantN) + } + + if !errors.Is(readErr, tt.wantErr) { + t.Errorf("Read() got err = %v, want %v", readErr, tt.wantErr) + } + + if tt.wantN > 0 { + got := string(buf[:tt.wantN]) + if got != tt.wantData { + t.Errorf("Read() got data = %q, want %q", got, tt.wantData) + } + } + }) + } +}