Skip to content

Commit 478f53b

Browse files
authored
[deckhouse-cli] fix kubectl signal and rewrite command (#358)
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
1 parent 1e30766 commit 478f53b

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

cmd/commands/kubectl.go

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,20 @@ import (
2222
"fmt"
2323
"io"
2424
"os"
25+
"os/signal"
2526
"regexp"
27+
"syscall"
2628
"time"
2729

2830
"github.com/spf13/cobra"
2931
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3032
"k8s.io/cli-runtime/pkg/genericclioptions"
33+
"k8s.io/cli-runtime/pkg/genericiooptions"
3134
"k8s.io/client-go/kubernetes"
3235
cliflag "k8s.io/component-base/cli/flag"
3336
"k8s.io/component-base/logs"
3437
kubecmd "k8s.io/kubectl/pkg/cmd"
38+
"k8s.io/kubectl/pkg/cmd/plugin"
3539
)
3640

3741
const (
@@ -42,6 +46,33 @@ const (
4246

4347
var d8CommandRegex = regexp.MustCompile("([\"'`])d8 (\\w+)")
4448

49+
// d8KubectlWriter wraps an io.Writer and rewrites kubectl's "d8 <subcmd>"
50+
// references (matched by d8CommandRegex) to "d8 k <subcmd>" on each Write call.
51+
//
52+
// kubectl uses os.Args[0] as the displayed command name in user-facing
53+
// suggestions (e.g. "You can run `d8 replace -f ...` to try this update again.").
54+
// Since the binary is invoked as "d8", those suggestions point users to a
55+
// non-existent top-level command. Wrapping IOStreams.ErrOut with this writer
56+
// ensures the suggestions are rewritten to the correct "d8 k <subcmd>" form
57+
// before reaching the terminal.
58+
type d8KubectlWriter struct {
59+
w io.Writer
60+
}
61+
62+
func newD8KubectlWriter(w io.Writer) *d8KubectlWriter {
63+
return &d8KubectlWriter{w: w}
64+
}
65+
66+
func (d *d8KubectlWriter) Write(p []byte) (int, error) {
67+
rewritten := d8CommandRegex.ReplaceAllString(string(p), "${1}d8 k ${2}")
68+
if _, err := d.w.Write([]byte(rewritten)); err != nil {
69+
return 0, err
70+
}
71+
// Report the original input length to honor the io.Writer contract even
72+
// though the rewritten payload may have a different byte length.
73+
return len(p), nil
74+
}
75+
4576
// wrapRunE wraps all RunE functions in the kubectl command tree to intercept stderr output.
4677
// This approach is preferred over modifying os.Args[0] because:
4778
// - It avoids modifying global state (os.Args) which could affect other parts of the system
@@ -126,12 +157,37 @@ func getDebugImage(cmd *cobra.Command) (string, error) {
126157
}
127158

128159
func NewKubectlCommand() *cobra.Command {
129-
kubectlCmd := kubecmd.NewDefaultKubectlCommand()
160+
// Build a kubectl command tree with stderr wrapped by d8KubectlWriter so
161+
// kubectl's "d8 <subcmd>" command hints are rewritten to "d8 k <subcmd>".
162+
//
163+
// This must be applied at construction time: kubectl captures os.Stderr
164+
// once into IOStreams.ErrOut and stores the reference (see
165+
// k8s.io/kubectl/pkg/cmd/cmd.go:NewDefaultKubectlCommand). Code paths that
166+
// write via that stored reference (e.g. the post-edit hint
167+
// "You can run `d8 replace -f ...`" emitted from editoptions.go) cannot be
168+
// intercepted by later swaps of the os.Stderr global.
169+
ioStreams := genericiooptions.IOStreams{
170+
In: os.Stdin,
171+
Out: os.Stdout,
172+
ErrOut: newD8KubectlWriter(os.Stderr),
173+
}
174+
175+
kubectlCmd := kubecmd.NewDefaultKubectlCommandWithArgs(kubecmd.KubectlOptions{
176+
PluginHandler: kubecmd.NewDefaultPluginHandler(plugin.ValidPluginFilenamePrefixes),
177+
Arguments: os.Args,
178+
ConfigFlags: genericclioptions.NewConfigFlags(true).
179+
WithDeprecatedPasswordFlag().
180+
WithDiscoveryBurst(300).
181+
WithDiscoveryQPS(50.0).
182+
WithWarningPrinter(ioStreams),
183+
IOStreams: ioStreams,
184+
})
130185
kubectlCmd.Use = "k"
131186
kubectlCmd.Aliases = []string{"kubectl"}
132187
kubectlCmd = ReplaceCommandName("kubectl", "d8 k", kubectlCmd)
133188

134-
// Wrap RunE to fix error messages
189+
// Fallback rewrite for kubectl code paths that write to the global
190+
// os.Stderr directly instead of using IOStreams.ErrOut.
135191
wrapRunE(kubectlCmd)
136192

137193
var debugCmd *cobra.Command
@@ -150,6 +206,22 @@ func NewKubectlCommand() *cobra.Command {
150206

151207
originalPersistentPreRunE := kubectlCmd.PersistentPreRunE
152208
kubectlCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
209+
// Restore default OS signal handling for the kubectl subtree.
210+
//
211+
// The d8 root command installs a graceful-termination signal handler
212+
// (see graceful.WithTermination in NewDeliveryCommand) that intercepts
213+
// SIGINT/SIGTERM, cancels the root context and then resets the signal
214+
// handlers. The kubectl subcommands (notably long-running ones such as
215+
// `proxy`, `port-forward`, `exec`, `attach`, `logs -f`) do not observe
216+
// that context and keep running until a second signal arrives and is
217+
// delivered to the default Go handler.
218+
//
219+
// To match standalone kubectl behavior (single SIGINT/SIGTERM stops the
220+
// command), drop our signal interceptor before kubectl starts so the
221+
// very first signal is delivered to the default handler and terminates
222+
// the process immediately.
223+
signal.Reset(syscall.SIGINT, syscall.SIGTERM)
224+
153225
if cmd.Name() == "debug" || (cmd.Parent() != nil && cmd.Parent().Name() == "debug") {
154226
imageFlag := cmd.Flags().Lookup("image")
155227
if imageFlag != nil && imageFlag.Value.String() == "" {

cmd/commands/kubectl_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package commands
1818

1919
import (
20+
"bytes"
2021
"testing"
2122
)
2223

@@ -167,3 +168,47 @@ func TestD8CommandRegexCaptureGroups(t *testing.T) {
167168
t.Errorf("Group 2 (command): expected %q, got %q", expectedGroup2, matches[2])
168169
}
169170
}
171+
172+
func TestD8KubectlWriter(t *testing.T) {
173+
tests := []struct {
174+
name string
175+
input string
176+
expected string
177+
}{
178+
{
179+
name: "kubectl edit retry hint with backticks",
180+
input: "You can run `d8 replace -f /tmp/d8-edit-3418513019.yaml` to try this update again.\n",
181+
expected: "You can run `d8 k replace -f /tmp/d8-edit-3418513019.yaml` to try this update again.\n",
182+
},
183+
{
184+
name: "non-matching content passes through unchanged",
185+
input: "error: the server doesn't have a resource type \"foobar\"\n",
186+
expected: "error: the server doesn't have a resource type \"foobar\"\n",
187+
},
188+
{
189+
name: "multiple references on the same line",
190+
input: "Try `d8 get pods` or `d8 describe pod` for details.\n",
191+
expected: "Try `d8 k get pods` or `d8 k describe pod` for details.\n",
192+
},
193+
}
194+
195+
for _, tt := range tests {
196+
t.Run(tt.name, func(t *testing.T) {
197+
var buf bytes.Buffer
198+
w := newD8KubectlWriter(&buf)
199+
200+
n, err := w.Write([]byte(tt.input))
201+
if err != nil {
202+
t.Fatalf("Write returned error: %v", err)
203+
}
204+
205+
if n != len(tt.input) {
206+
t.Errorf("Write returned n=%d, want %d (input length)", n, len(tt.input))
207+
}
208+
209+
if got := buf.String(); got != tt.expected {
210+
t.Errorf("\nInput: %q\nExpected: %q\nGot: %q", tt.input, tt.expected, got)
211+
}
212+
})
213+
}
214+
}

0 commit comments

Comments
 (0)