From d340b72bf14cb2b2dbc263dc86b5590094774076 Mon Sep 17 00:00:00 2001 From: Jayson Wang Date: Mon, 25 Dec 2023 02:18:47 +0800 Subject: [PATCH] adding cancelable launch prompts to NodeShell (#2360) --- internal/ui/dialog/prompt.go | 57 ++++++++++++++++++++++++ internal/ui/dialog/prompt_test.go | 46 ++++++++++++++++++++ internal/view/exec.go | 72 +++++++++++++++++++++++-------- internal/view/node.go | 5 +-- 4 files changed, 157 insertions(+), 23 deletions(-) create mode 100644 internal/ui/dialog/prompt.go create mode 100644 internal/ui/dialog/prompt_test.go diff --git a/internal/ui/dialog/prompt.go b/internal/ui/dialog/prompt.go new file mode 100644 index 0000000000..622ae4a991 --- /dev/null +++ b/internal/ui/dialog/prompt.go @@ -0,0 +1,57 @@ +package dialog + +import ( + "context" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" +) + +type promptAction func(ctx context.Context) + +// ShowPrompt pops a prompt dialog. +func ShowPrompt(styles config.Dialog, pages *ui.Pages, title, msg string, action promptAction, cancel cancelFunc) { + f := tview.NewForm() + f.SetItemPadding(0) + f.SetButtonsAlign(tview.AlignCenter). + SetButtonBackgroundColor(styles.ButtonBgColor.Color()). + SetButtonTextColor(styles.ButtonFgColor.Color()). + SetLabelColor(styles.LabelFgColor.Color()). + SetFieldTextColor(styles.FieldFgColor.Color()) + + ctx, cancelCtx := context.WithCancel(context.Background()) + + f.AddButton("Cancel", func() { + dismiss(pages) + cancelCtx() + cancel() + }) + + for i := 0; i < f.GetButtonCount(); i++ { + b := f.GetButton(i) + if b == nil { + continue + } + b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) + b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) + } + + f.SetFocus(0) + modal := tview.NewModalForm("<"+title+">", f) + modal.SetText(msg) + modal.SetTextColor(styles.FgColor.Color()) + modal.SetDoneFunc(func(int, string) { + dismiss(pages) + cancelCtx() + cancel() + }) + + pages.AddPage(dialogKey, modal, false, false) + pages.ShowPage(dialogKey) + + go func() { + action(ctx) + dismiss(pages) + }() +} diff --git a/internal/ui/dialog/prompt_test.go b/internal/ui/dialog/prompt_test.go new file mode 100644 index 0000000000..7d4ade05fa --- /dev/null +++ b/internal/ui/dialog/prompt_test.go @@ -0,0 +1,46 @@ +package dialog + +import ( + "context" + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tcell/v2" + "github.com/derailed/tview" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestShowPrompt(t *testing.T) { + t.Run("waiting done", func(t *testing.T) { + a := tview.NewApplication() + p := ui.NewPages() + a.SetRoot(p, false) + + ShowPrompt(config.Dialog{}, p, "Running", "Pod", func(context.Context) { + time.Sleep(time.Millisecond) + }, func() { + t.Errorf("unexpected cancellations") + }) + }) + + t.Run("canceled", func(t *testing.T) { + a := tview.NewApplication() + p := ui.NewPages() + a.SetRoot(p, false) + + go ShowPrompt(config.Dialog{}, p, "Running", "Pod", func(ctx context.Context) { + select { + case <-time.After(time.Second): + t.Errorf("expected cancellations") + case <-ctx.Done(): + } + }, func() {}) + + time.Sleep(time.Second / 2) + d := p.GetPrimitive(dialogKey).(*tview.ModalForm) + if assert.NotNil(t, d) { + d.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, '\n', 0), func(tview.Primitive) {}) + } + }) +} diff --git a/internal/view/exec.go b/internal/view/exec.go index f4f45bf4e7..ed54d98888 100644 --- a/internal/view/exec.go +++ b/internal/view/exec.go @@ -18,6 +18,8 @@ import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/ui/dialog" "github.com/fatih/color" "github.com/rs/zerolog/log" v1 "k8s.io/api/core/v1" @@ -233,25 +235,50 @@ func clearScreen() { const ( k9sShell = "k9s-shell" - k9sShellRetryCount = 10 - k9sShellRetryDelay = 10 * time.Second + k9sShellRetryCount = 50 + k9sShellRetryDelay = 2 * time.Second ) -func ssh(a *App, node string) error { +func launchNodeShell(v model.Igniter, a *App, node string) { if err := nukeK9sShell(a); err != nil { - return err + a.Flash().Errf("Cleaning node shell failed: %s", err) + return } + + msg := fmt.Sprintf("Launching node shell on %s...", node) + dialog.ShowPrompt(a.Styles.Dialog(), a.Content.Pages, "Launching", msg, func(ctx context.Context) { + err := launchShellPod(ctx, a, node) + if err != nil { + if !errors.Is(err, context.Canceled) { + a.Flash().Errf("Launching node shell failed: %s", err) + } + return + } + + go launchPodShell(v, a) + }, func() { + if err := nukeK9sShell(a); err != nil { + a.Flash().Errf("Cleaning node shell failed: %s", err) + return + } + }) +} + +func launchPodShell(v model.Igniter, a *App) { defer func() { if err := nukeK9sShell(a); err != nil { - log.Error().Err(err).Msgf("nuking k9s shell pod") + a.Flash().Errf("Launching node shell failed: %s", err) + return } }() - if err := launchShellPod(a, node); err != nil { - return err - } - ns := a.Config.K9s.ShellPod.Namespace - return sshIn(a, client.FQN(ns, k9sShellPodName()), k9sShell) + v.Stop() + defer v.Start() + + ns := a.Config.K9s.ShellPod.Namespace + if err := sshIn(a, client.FQN(ns, k9sShellPodName()), k9sShell); err != nil { + a.Flash().Errf("Launching node shell failed: %s", err) + } } func sshIn(a *App, fqn, co string) error { @@ -309,31 +336,33 @@ func nukeK9sShell(a *App) error { return err } -func launchShellPod(a *App, node string) error { - a.Flash().Infof("Launching node shell on %s...", node) - +func launchShellPod(ctx context.Context, a *App, node string) error { var ( spo = a.Config.K9s.ShellPod spec = k9sShellPod(node, spo) ) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() dial, err := a.Conn().Dial() if err != nil { return err } + conn := dial.CoreV1().Pods(spo.Namespace) - if _, err := conn.Create(ctx, spec, metav1.CreateOptions{}); err != nil { + if _, err = conn.Create(ctx, spec, metav1.CreateOptions{}); err != nil { return err } for i := 0; i < k9sShellRetryCount; i++ { o, err := a.factory.Get("v1/pods", client.FQN(spo.Namespace, k9sShellPodName()), true, labels.Everything()) if err != nil { - time.Sleep(k9sShellRetryDelay) - continue + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(k9sShellRetryDelay): + continue + } } + var pod v1.Pod if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod); err != nil { return err @@ -342,7 +371,12 @@ func launchShellPod(a *App, node string) error { if pod.Status.Phase == v1.PodRunning { return nil } - time.Sleep(k9sShellRetryDelay) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(k9sShellRetryDelay): + } } return fmt.Errorf("unable to launch shell pod on node %s", node) diff --git a/internal/view/node.go b/internal/view/node.go index 6e3b17cbe4..636031953d 100644 --- a/internal/view/node.go +++ b/internal/view/node.go @@ -14,7 +14,6 @@ import ( "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" - "github.com/rs/zerolog/log" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -161,9 +160,7 @@ func (n *Node) sshCmd(evt *tcell.EventKey) *tcell.EventKey { n.Stop() defer n.Start() _, node := client.Namespaced(path) - if err := ssh(n.App(), node); err != nil { - log.Error().Err(err).Msgf("Node Shell Failed") - } + launchNodeShell(n, n.App(), node) return nil }