diff --git a/internal/dao/dp.go b/internal/dao/dp.go index b4ad9b34..a5fb19c5 100644 --- a/internal/dao/dp.go +++ b/internal/dao/dp.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/derailed/k9s/internal/client" "github.com/rs/zerolog/log" @@ -18,12 +19,13 @@ import ( ) var ( - _ Accessor = (*Deployment)(nil) - _ Nuker = (*Deployment)(nil) - _ Loggable = (*Deployment)(nil) - _ Restartable = (*Deployment)(nil) - _ Scalable = (*Deployment)(nil) - _ Controller = (*Deployment)(nil) + _ Accessor = (*Deployment)(nil) + _ Nuker = (*Deployment)(nil) + _ Loggable = (*Deployment)(nil) + _ Restartable = (*Deployment)(nil) + _ Scalable = (*Deployment)(nil) + _ Controller = (*Deployment)(nil) + _ ContainsPodSpec = (*Deployment)(nil) ) // Deployment represents a deployment K8s resource. @@ -211,6 +213,48 @@ func (d *Deployment) Scan(ctx context.Context, gvr, fqn string, wait bool) (Refs return refs, nil } +func (d *Deployment) GetPodSpec(path string) (*v1.PodSpec, error) { + dp, err := d.Load(d.Factory, path) + if err != nil { + return nil, err + } + podSpec := dp.Spec.Template.Spec + return &podSpec, nil +} + +func (d *Deployment) SetImages(ctx context.Context, path string, images map[string]string) error { + ns, n := client.Namespaced(path) + auth, err := d.Client().CanI(ns, "apps/v1/deployments", []string{client.PatchVerb}) + if err != nil { + return err + } + if !auth { + return fmt.Errorf("user is not authorized to patch a deployment") + } + var nameStrB strings.Builder + var imageStrB strings.Builder + + for name, image := range images { + nameStrB.WriteString(fmt.Sprintf(`{"name":"%s"},`, name)) + imageStrB.WriteString(fmt.Sprintf(`{"image":"%s","name":"%s"},`, image, name)) + } + namesJson := strings.TrimSuffix(nameStrB.String(), ",") + imagesJson := strings.TrimSuffix(imageStrB.String(), ",") + patchJson := `{"spec":{"template":{"spec":{"$setElementOrder/containers":[` + namesJson + `],"containers":[` + imagesJson + `]}}}}` + dial, err := d.Client().Dial() + if err != nil { + return err + } + _, err = dial.AppsV1().Deployments(ns).Patch( + ctx, + n, + types.StrategicMergePatchType, + []byte(patchJson), + metav1.PatchOptions{}, + ) + return err +} + func hasPVC(spec *v1.PodSpec, name string) bool { for _, v := range spec.Volumes { if v.PersistentVolumeClaim != nil && v.PersistentVolumeClaim.ClaimName == name { diff --git a/internal/dao/types.go b/internal/dao/types.go index ddf671a6..2adfc25e 100644 --- a/internal/dao/types.go +++ b/internal/dao/types.go @@ -146,3 +146,11 @@ type Logger interface { // Logs tails a resource logs. Logs(path string, opts *v1.PodLogOptions) (*restclient.Request, error) } + +type ContainsPodSpec interface { + // Get PodSpec of a resource + GetPodSpec(path string) (*v1.PodSpec, error) + + // Set Images for a resource + SetImages(ctx context.Context, path string, images map[string]string) error +} diff --git a/internal/view/dp.go b/internal/view/dp.go index f5acbcaa..8fc65a93 100644 --- a/internal/view/dp.go +++ b/internal/view/dp.go @@ -21,9 +21,11 @@ func NewDeploy(gvr client.GVR) ResourceViewer { ResourceViewer: NewPortForwardExtender( NewRestartExtender( NewScaleExtender( - NewLogsExtender( - NewBrowser(gvr), - nil, + NewSetImageExtender( + NewLogsExtender( + NewBrowser(gvr), + nil, + ), ), ), ), diff --git a/internal/view/dp_test.go b/internal/view/dp_test.go index d69fa571..c78a9e00 100644 --- a/internal/view/dp_test.go +++ b/internal/view/dp_test.go @@ -13,5 +13,5 @@ func TestDeploy(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Deployments", v.Name()) - assert.Equal(t, 12, len(v.Hints())) + assert.Equal(t, 13, len(v.Hints())) } diff --git a/internal/view/set_image_extender.go b/internal/view/set_image_extender.go new file mode 100644 index 00000000..94592a67 --- /dev/null +++ b/internal/view/set_image_extender.go @@ -0,0 +1,136 @@ +package view + +import ( + "context" + "fmt" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/core/v1" +) + +// SetImageExtender adds set image extensions +type SetImageExtender struct { + ResourceViewer +} + +const setImageKey = "setImage" + +func NewSetImageExtender(r ResourceViewer) ResourceViewer { + s := SetImageExtender{ResourceViewer: r} + s.bindKeys(s.Actions()) + + return &s +} + +func (s *SetImageExtender) bindKeys(aa ui.KeyActions) { + aa.Add(ui.KeyActions{ + ui.KeyI: ui.NewKeyAction("SetImage", s.setImageCmd, true), + }) +} + +func (s *SetImageExtender) setImageCmd(evt *tcell.EventKey) *tcell.EventKey { + path := s.GetTable().GetSelectedItem() + if path == "" { + return nil + } + + s.Stop() + defer s.Start() + s.showSetImageDialog(path) + + return nil +} + +func (s *SetImageExtender) showSetImageDialog(path string) { + confirm := tview.NewModalForm("", s.makeSetImageForm(path)) + confirm.SetText(fmt.Sprintf("Set image %s %s", s.GVR(), path)) + confirm.SetDoneFunc(func(int, string) { + s.dismissDialog() + }) + s.App().Content.AddPage(setImageKey, confirm, false, false) + s.App().Content.ShowPage(setImageKey) +} + +func (s *SetImageExtender) makeSetImageForm(sel string) *tview.Form { + f := s.makeStyledForm() + containers, err := s.getImages(sel) + if err != nil { + s.App().Flash().Err(err) + return nil + } + images := make(map[string]string) + for _, container := range *containers { + f.AddInputField(container.Name, container.Image, 0, nil, func(changed string) { + images[container.Name] = changed + }) + } + + f.AddButton("OK", func() { + defer s.dismissDialog() + + if err != nil { + s.App().Flash().Err(err) + return + } + ctx, cancel := context.WithTimeout(context.Background(), s.App().Conn().Config().CallTimeout()) + defer cancel() + if err := s.setImages(ctx, sel, images); err != nil { + log.Error().Err(err).Msgf("DP %s image update failed", sel) + s.App().Flash().Err(err) + } else { + s.App().Flash().Infof("Resource %s:%s image updated successfully", s.GVR(), sel) + } + }) + + f.AddButton("Cancel", func() { + s.dismissDialog() + }) + + return f +} + +func (s *SetImageExtender) dismissDialog() { + s.App().Content.RemovePage(setImageKey) +} + +func (s *SetImageExtender) makeStyledForm() *tview.Form { + f := tview.NewForm() + f.SetItemPadding(0) + f.SetButtonsAlign(tview.AlignCenter). + SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor). + SetButtonTextColor(tview.Styles.PrimaryTextColor). + SetLabelColor(tcell.ColorAqua). + SetFieldTextColor(tcell.ColorOrange) + return f +} + +func (s *SetImageExtender) getImages(path string) (*[]corev1.Container, error) { + res, err := dao.AccessorFor(s.App().factory, s.GVR()) + if err != nil { + return nil, err + } + podSpecable, ok := res.(dao.ContainsPodSpec) + if !ok { + return nil, fmt.Errorf("expecting a podSpecable resource for %q", s.GVR()) + } + podSpec, err := podSpecable.GetPodSpec(path) + containers := podSpec.Containers + return &containers, nil +} + +func (s *SetImageExtender) setImages(ctx context.Context, path string, images map[string]string) error { + res, err := dao.AccessorFor(s.App().factory, s.GVR()) + if err != nil { + return err + } + + deployment, ok := res.(dao.ContainsPodSpec) + if !ok { + return fmt.Errorf("expecting a scalable resource for %q", s.GVR()) + } + + return deployment.SetImages(ctx, path, images) +}