From c5ee02795df782d370ca585f840bd9d3a32a8d06 Mon Sep 17 00:00:00 2001 From: Thorben Below <56894536+thorbenbelow@users.noreply.github.com> Date: Sun, 10 Nov 2024 17:25:19 +0100 Subject: [PATCH] feat: add pause and resume cmds Signed-off-by: Thorben Below <56894536+thorbenbelow@users.noreply.github.com> --- internal/dao/dp.go | 24 +++++ internal/dao/types.go | 6 ++ internal/render/dp.go | 2 + internal/view/dp.go | 8 +- internal/view/dp_test.go | 2 +- internal/view/pause_extender.go | 181 ++++++++++++++++++++++++++++++++ internal/view/types.go | 1 + 7 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 internal/view/pause_extender.go diff --git a/internal/dao/dp.go b/internal/dao/dp.go index 5df3432113..0bbfa3275e 100644 --- a/internal/dao/dp.go +++ b/internal/dao/dp.go @@ -123,6 +123,30 @@ func (d *Deployment) Restart(ctx context.Context, path string) error { return err } +// Pause a Deployment +func (d *Deployment) Pause(ctx context.Context, path string) error { + ns, n := client.Namespaced(path) + dial, err := d.Client().Dial() + if err != nil { + return err + } + _, err = dial.AppsV1().Deployments(ns).Patch(ctx, n, types.MergePatchType, []byte(`{"spec": {"paused": true}}`), metav1.PatchOptions{}) + + return err +} + +// Resume a paused Deployment +func (d *Deployment) Resume(ctx context.Context, path string) error { + ns, n := client.Namespaced(path) + dial, err := d.Client().Dial() + if err != nil { + return err + } + _, err = dial.AppsV1().Deployments(ns).Patch(ctx, n, types.MergePatchType, []byte(`{"spec": {"paused": false}}`), metav1.PatchOptions{}) + + return err +} + // TailLogs tail logs for all pods represented by this Deployment. func (d *Deployment) TailLogs(ctx context.Context, opts *LogOptions) ([]LogChan, error) { dp, err := d.GetInstance(opts.Path) diff --git a/internal/dao/types.go b/internal/dao/types.go index da6fc7ff53..11a26558b1 100644 --- a/internal/dao/types.go +++ b/internal/dao/types.go @@ -120,6 +120,12 @@ type Scalable interface { Scale(ctx context.Context, path string, replicas int32) error } +// Pausable represents resources that can be paused/resumed +type Pausable interface { + Pause(ctx context.Context, path string) error + Resume(ctx context.Context, path string) error +} + // Controller represents a pod controller. type Controller interface { // Pod returns a pod instance matching the selector. diff --git a/internal/render/dp.go b/internal/render/dp.go index 1444eeb99a..33a63b672f 100644 --- a/internal/render/dp.go +++ b/internal/render/dp.go @@ -50,6 +50,7 @@ func (Deployment) Header(ns string) model1.Header { model1.HeaderColumn{Name: "READY", Align: tview.AlignRight}, model1.HeaderColumn{Name: "UP-TO-DATE", Align: tview.AlignRight}, model1.HeaderColumn{Name: "AVAILABLE", Align: tview.AlignRight}, + model1.HeaderColumn{Name: "PAUSED"}, model1.HeaderColumn{Name: "LABELS", Wide: true}, model1.HeaderColumn{Name: "VALID", Wide: true}, model1.HeaderColumn{Name: "AGE", Time: true}, @@ -77,6 +78,7 @@ func (d Deployment) Render(o interface{}, ns string, r *model1.Row) error { strconv.Itoa(int(dp.Status.AvailableReplicas)) + "/" + strconv.Itoa(int(dp.Status.Replicas)), strconv.Itoa(int(dp.Status.UpdatedReplicas)), strconv.Itoa(int(dp.Status.AvailableReplicas)), + strconv.FormatBool(dp.Spec.Paused), mapToStr(dp.Labels), AsStatus(d.diagnose(dp.Status.Replicas, dp.Status.AvailableReplicas)), ToAge(dp.GetCreationTimestamp()), diff --git a/internal/view/dp.go b/internal/view/dp.go index 3c6760c84b..2c08da749d 100644 --- a/internal/view/dp.go +++ b/internal/view/dp.go @@ -27,9 +27,11 @@ func NewDeploy(gvr client.GVR) ResourceViewer { NewVulnerabilityExtender( NewRestartExtender( NewScaleExtender( - NewImageExtender( - NewOwnerExtender( - NewLogsExtender(NewBrowser(gvr), d.logOptions), + NewPauseExtender( + NewImageExtender( + NewOwnerExtender( + NewLogsExtender(NewBrowser(gvr), d.logOptions), + ), ), ), ), diff --git a/internal/view/dp_test.go b/internal/view/dp_test.go index a3e8a51fd4..4a360b0377 100644 --- a/internal/view/dp_test.go +++ b/internal/view/dp_test.go @@ -16,5 +16,5 @@ func TestDeploy(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Deployments", v.Name()) - assert.Equal(t, 16, len(v.Hints())) + assert.Equal(t, 17, len(v.Hints())) } diff --git a/internal/view/pause_extender.go b/internal/view/pause_extender.go new file mode 100644 index 0000000000..5ea62e4174 --- /dev/null +++ b/internal/view/pause_extender.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package view + +import ( + "context" + "fmt" + + "github.com/derailed/k9s/internal/config" + + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tcell/v2" + "github.com/derailed/tview" + "github.com/rs/zerolog/log" +) + +// PauseExtender adds pausing extensions. +type PauseExtender struct { + ResourceViewer +} + +// NewPauseExtender returns a new extender. +func NewPauseExtender(r ResourceViewer) ResourceViewer { + p := PauseExtender{ResourceViewer: r} + p.AddBindKeysFn(p.bindKeys) + + return &p +} + +const ( + PAUSE = "Pause" + RESUME = "Resume" + PAUSE_RESUME = "Pause/Resume" +) + +func (p *PauseExtender) bindKeys(aa *ui.KeyActions) { + if p.App().Config.K9s.IsReadOnly() { + return + } + + aa.Add(ui.KeyZ, ui.NewKeyActionWithOpts(PAUSE_RESUME, p.togglePauseCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }, + )) +} + +func (p *PauseExtender) togglePauseCmd(evt *tcell.EventKey) *tcell.EventKey { + paths := p.GetTable().GetSelectedItems() + if len(paths) == 0 { + return nil + } + + p.Stop() + defer p.Start() + + styles := p.App().Styles.Dialog() + form := p.makeStyledForm(styles) + + action := PAUSE + if len(paths) == 1 { + isPaused, err := p.valueOf("PAUSED") + if err != nil { + log.Error().Err(err).Msg("Reading 'PAUSED' state failed") + p.App().Flash().Err(err) + return nil + } + + if isPaused == "true" { + action = RESUME + } + } + + if len(paths) > 1 { + form.AddDropDown("Action:", []string{PAUSE, RESUME}, 0, func(option string, optionIndex int) { + action = option + }) + } + + form.AddButton("OK", func() { + defer p.dismissDialog() + + ctx, cancel := context.WithTimeout(context.Background(), p.App().Conn().Config().CallTimeout()) + defer cancel() + + for _, sel := range paths { + if err := p.togglePause(ctx, sel, action); err != nil { + log.Error().Err(err).Msgf("DP %s pausing failed", sel) + p.App().Flash().Err(err) + return + } + } + + if len(paths) == 1 { + p.App().Flash().Infof("[%d] %s paused successfully", len(paths), singularize(p.GVR().R())) + } else { + p.App().Flash().Infof("%s %s paused successfully", p.GVR().R(), paths[0]) + } + }) + + form.AddButton("Cancel", func() { + p.dismissDialog() + }) + for i := 0; i < 2; i++ { + if b := form.GetButton(i); b != nil { + b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) + b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) + } + } + + confirm := tview.NewModalForm("Pause/Resume", form) + msg := fmt.Sprintf("%s %s %s?", action, singularize(p.GVR().R()), paths[0]) + if len(paths) > 1 { + msg = fmt.Sprintf("Pause/Resume [%d] %s?", len(paths), p.GVR().R()) + } + confirm.SetText(msg) + confirm.SetDoneFunc(func(int, string) { + p.dismissDialog() + }) + p.App().Content.AddPage(pauseDialogKey, confirm, false, false) + p.App().Content.ShowPage(pauseDialogKey) + + return nil +} + +func (p *PauseExtender) togglePause(ctx context.Context, path string, action string) error { + res, err := dao.AccessorFor(p.App().factory, p.GVR()) + if err != nil { + p.App().Flash().Err(err) + return nil + } + pauser, ok := res.(dao.Pausable) + if !ok { + p.App().Flash().Err(fmt.Errorf("expecting a pausable resource for %q", p.GVR())) + return nil + } + + if action == PAUSE { + err = pauser.Pause(ctx, path) + } else if action == RESUME { + err = pauser.Resume(ctx, path) + } else { + p.App().Flash().Err(fmt.Errorf("failed to identify action; must be '%s' or '%s' but is: '%s'", PAUSE, RESUME, action)) + return nil + } + + if err != nil { + p.App().Flash().Err(fmt.Errorf("failed to %s: %q", action, err)) + } + + return nil +} + +func (p *PauseExtender) valueOf(col string) (string, error) { + colIdx, ok := p.GetTable().HeaderIndex(col) + if !ok { + return "", fmt.Errorf("no column index for %s", col) + } + return p.GetTable().GetSelectedCell(colIdx), nil +} + +const pauseDialogKey = "pause" + +func (p *PauseExtender) dismissDialog() { + p.App().Content.RemovePage(pauseDialogKey) +} + +func (p *PauseExtender) makeStyledForm(styles config.Dialog) *tview.Form { + f := tview.NewForm() + f.SetItemPadding(0) + f.SetButtonsAlign(tview.AlignCenter). + SetButtonBackgroundColor(styles.ButtonBgColor.Color()). + SetButtonTextColor(styles.ButtonBgColor.Color()). + SetLabelColor(styles.LabelFgColor.Color()). + SetFieldTextColor(styles.FieldFgColor.Color()) + + return f +} diff --git a/internal/view/types.go b/internal/view/types.go index 76a4c9a624..03df93c35e 100644 --- a/internal/view/types.go +++ b/internal/view/types.go @@ -21,6 +21,7 @@ const ( uptodateCol = "UP-TO-DATE" readyCol = "READY" availCol = "AVAILABLE" + pausedCol = "PAUSED" ) type (