diff --git a/go.mod b/go.mod index 1f57d7a3..c9d56117 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sahilm/fuzzy v0.1.0 github.com/spf13/cobra v1.0.0 - github.com/stretchr/testify v1.5.1 + github.com/stretchr/testify v1.6.1 golang.org/x/net v0.0.0-20200519113804-d87ec0cfa476 // indirect golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 // indirect golang.org/x/text v0.3.2 diff --git a/go.sum b/go.sum index 2c16ef52..fd70fa87 100644 --- a/go.sum +++ b/go.sum @@ -613,6 +613,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -837,6 +839,8 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= helm.sh/helm/v3 v3.2.0 h1:V12EGAmr2DJ/fWrPo2fPdXWSIXvlXm51vGkQIXMeymE= diff --git a/internal/dao/dp.go b/internal/dao/dp.go index b4ad9b34..78a430fe 100644 --- a/internal/dao/dp.go +++ b/internal/dao/dp.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "github.com/derailed/k9s/internal/client" "github.com/rs/zerolog/log" appsv1 "k8s.io/api/apps/v1" @@ -18,12 +17,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 +211,42 @@ 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, imageSpecs ImageSpecs) 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") + } + jsonPatch, err := GetTemplateJsonPatch(imageSpecs) + if err != nil { + return err + } + dial, err := d.Client().Dial() + if err != nil { + return err + } + _, err = dial.AppsV1().Deployments(ns).Patch( + ctx, + n, + types.StrategicMergePatchType, + jsonPatch, + 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/ds.go b/internal/dao/ds.go index 78e99fdd..5c0232ae 100644 --- a/internal/dao/ds.go +++ b/internal/dao/ds.go @@ -21,11 +21,12 @@ import ( ) var ( - _ Accessor = (*DaemonSet)(nil) - _ Nuker = (*DaemonSet)(nil) - _ Loggable = (*DaemonSet)(nil) - _ Restartable = (*DaemonSet)(nil) - _ Controller = (*DaemonSet)(nil) + _ Accessor = (*DaemonSet)(nil) + _ Nuker = (*DaemonSet)(nil) + _ Loggable = (*DaemonSet)(nil) + _ Restartable = (*DaemonSet)(nil) + _ Controller = (*DaemonSet)(nil) + _ ContainsPodSpec = (*DaemonSet)(nil) ) // DaemonSet represents a K8s daemonset. @@ -225,6 +226,42 @@ func (d *DaemonSet) Scan(ctx context.Context, gvr, fqn string, wait bool) (Refs, return refs, nil } +func (d *DaemonSet) GetPodSpec(path string) (*v1.PodSpec, error) { + ds, err := d.GetInstance(path) + if err != nil { + return nil, err + } + podSpec := ds.Spec.Template.Spec + return &podSpec, nil +} + +func (d *DaemonSet) SetImages(ctx context.Context, path string, imageSpecs ImageSpecs) error { + ns, n := client.Namespaced(path) + auth, err := d.Client().CanI(ns, "apps/v1/daemonset", []string{client.PatchVerb}) + if err != nil { + return err + } + if !auth { + return fmt.Errorf("user is not authorized to patch a daemonset") + } + jsonPatch, err := GetTemplateJsonPatch(imageSpecs) + if err != nil { + return err + } + dial, err := d.Client().Dial() + if err != nil { + return err + } + _, err = dial.AppsV1().DaemonSets(ns).Patch( + ctx, + n, + types.StrategicMergePatchType, + jsonPatch, + metav1.PatchOptions{}, + ) + return err +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/dao/patch.go b/internal/dao/patch.go new file mode 100644 index 00000000..769e160d --- /dev/null +++ b/internal/dao/patch.go @@ -0,0 +1,78 @@ +package dao + +import ( + "encoding/json" +) + +type ImageSpec struct { + Index int + Name, DockerImage string + Init bool +} + +type ImageSpecs []ImageSpec + +type JsonPatch struct { + Spec Spec `json:"spec"` +} + +type Spec struct { + Template PodSpec `json:"template"` +} + +type PodSpec struct { + Spec ImagesSpec `json:"spec"` +} + +type ImagesSpec struct { + SetElementOrderContainers []Element `json:"$setElementOrder/containers,omitempty"` + SetElementOrderInitContainers []Element `json:"$setElementOrder/initContainers,omitempty"` + Containers []Element `json:"containers,omitempty"` + InitContainers []Element `json:"initContainers,omitempty"` +} + +type Element struct { + Image string `json:"image,omitempty"` + Name string `json:"name"` +} + +// Build a json patch string to update PodSpec images +func GetTemplateJsonPatch(imageSpecs ImageSpecs) ([]byte, error) { + jsonPatch := JsonPatch{ + Spec: Spec{ + Template: getPatchPodSpec(imageSpecs), + }, + } + return json.Marshal(jsonPatch) +} + +func GetJsonPatch(imageSpecs ImageSpecs) ([]byte, error) { + podSpec := getPatchPodSpec(imageSpecs) + return json.Marshal(podSpec) +} + +func getPatchPodSpec(imageSpecs ImageSpecs) PodSpec { + initElementsOrders, initElements, elementsOrders, elements := extractElements(imageSpecs) + podSpec := PodSpec{ + Spec: ImagesSpec{ + SetElementOrderInitContainers: initElementsOrders, + InitContainers: initElements, + SetElementOrderContainers: elementsOrders, + Containers: elements, + }, + } + return podSpec +} + +func extractElements(imageSpecs ImageSpecs) (initElementsOrders []Element, initElements []Element, elementsOrders []Element, elements []Element) { + for _, spec := range imageSpecs { + if spec.Init { + initElementsOrders = append(initElementsOrders, Element{Name: spec.Name}) + initElements = append(initElements, Element{Name: spec.Name, Image: spec.DockerImage}) + } else { + elementsOrders = append(elementsOrders, Element{Name: spec.Name}) + elements = append(elements, Element{Name: spec.Name, Image: spec.DockerImage}) + } + } + return initElementsOrders, initElements, elementsOrders, elements +} diff --git a/internal/dao/patch_test.go b/internal/dao/patch_test.go new file mode 100644 index 00000000..439a3fc7 --- /dev/null +++ b/internal/dao/patch_test.go @@ -0,0 +1,90 @@ +package dao + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestGetTemplateJsonPatch(t *testing.T) { + type args struct { + imageSpecs ImageSpecs + } + tests := map[string]struct { + args args + want string + wantErr bool + }{ + "simple": { + args: args{ + imageSpecs: ImageSpecs{ + ImageSpec{ + Index: 0, + Name: "init", + DockerImage: "busybox:latest", + Init: true, + }, + ImageSpec{ + Index: 0, + Name: "nginx", + DockerImage: "nginx:latest", + Init: false, + }, + }, + }, + want: `{"spec":{"template":{"spec":{"$setElementOrder/initContainers":[{"name":"init"}],"$setElementOrder/containers":[{"name":"nginx"}],"initContainers":[{"image":"busybox:latest","name":"init"}],"containers":[{"image":"nginx:latest","name":"nginx"}]}}}}`, + wantErr: false, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := GetTemplateJsonPatch(tt.args.imageSpecs) + if (err != nil) != tt.wantErr { + t.Errorf("GetTemplateJsonPatch() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.JSONEq(t, tt.want, string(got), "Json strings should be equal") + }) + } +} + +func TestGetJsonPatch(t *testing.T) { + type args struct { + imageSpecs ImageSpecs + } + tests := map[string]struct { + args args + want string + wantErr bool + }{ + "simple": { + args: args{ + imageSpecs: ImageSpecs{ + ImageSpec{ + Index: 0, + Name: "init", + DockerImage: "busybox:latest", + Init: true, + }, + ImageSpec{ + Index: 0, + Name: "nginx", + DockerImage: "nginx:latest", + Init: false, + }, + }, + }, + want: `{"spec":{"$setElementOrder/initContainers":[{"name":"init"}],"initContainers":[{"image":"busybox:latest","name":"init"}],"$setElementOrder/containers":[{"name":"nginx"}],"containers":[{"image":"nginx:latest","name":"nginx"}]}}`, + wantErr: false, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := GetJsonPatch(tt.args.imageSpecs) + if (err != nil) != tt.wantErr { + t.Errorf("GetTemplateJsonPatch() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.JSONEq(t, tt.want, string(got), "Json strings should be equal") + }) + } +} diff --git a/internal/dao/pod.go b/internal/dao/pod.go index f607341f..13169fce 100644 --- a/internal/dao/pod.go +++ b/internal/dao/pod.go @@ -18,15 +18,17 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" restclient "k8s.io/client-go/rest" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) var ( - _ Accessor = (*Pod)(nil) - _ Nuker = (*Pod)(nil) - _ Loggable = (*Pod)(nil) - _ Controller = (*Pod)(nil) + _ Accessor = (*Pod)(nil) + _ Nuker = (*Pod)(nil) + _ Loggable = (*Pod)(nil) + _ Controller = (*Pod)(nil) + _ ContainsPodSpec = (*Pod)(nil) ) const ( @@ -455,3 +457,57 @@ func in(ll []string, s string) bool { } return false } + +func (p *Pod) GetPodSpec(path string) (*v1.PodSpec, error) { + pod, err := p.GetInstance(path) + if err != nil { + return nil, err + } + podSpec := pod.Spec + return &podSpec, nil +} + +func (p *Pod) SetImages(ctx context.Context, path string, imageSpecs ImageSpecs) error { + ns, n := client.Namespaced(path) + auth, err := p.Client().CanI(ns, "v1/pod", []string{client.PatchVerb}) + if err != nil { + return err + } + if !auth { + return fmt.Errorf("user is not authorized to patch a deployment") + } + if manager, isManaged, err := p.isControlled(path); isManaged { + if err != nil { + return err + } + return fmt.Errorf("Unable to set image. This pod is managed by %s. Please set the image on the controller", manager) + } + jsonPatch, err := GetJsonPatch(imageSpecs) + if err != nil { + return err + } + dial, err := p.Client().Dial() + if err != nil { + return err + } + _, err = dial.CoreV1().Pods(ns).Patch( + ctx, + n, + types.StrategicMergePatchType, + jsonPatch, + metav1.PatchOptions{}, + ) + return err +} + +func (p *Pod) isControlled(path string) (string, bool, error) { + pod, err := p.GetInstance(path) + if err != nil { + return "", false, err + } + references := pod.GetObjectMeta().GetOwnerReferences() + if len(references) != 0 && *references[0].Controller == true { + return fmt.Sprintf("%s/%s", references[0].Kind, references[0].Name), true, nil + } + return "", false, nil +} diff --git a/internal/dao/sts.go b/internal/dao/sts.go index b0ea2b0a..8e7498ab 100644 --- a/internal/dao/sts.go +++ b/internal/dao/sts.go @@ -9,6 +9,7 @@ import ( "github.com/derailed/k9s/internal/client" "github.com/rs/zerolog/log" appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" @@ -18,12 +19,13 @@ import ( ) var ( - _ Accessor = (*StatefulSet)(nil) - _ Nuker = (*StatefulSet)(nil) - _ Loggable = (*StatefulSet)(nil) - _ Restartable = (*StatefulSet)(nil) - _ Scalable = (*StatefulSet)(nil) - _ Controller = (*StatefulSet)(nil) + _ Accessor = (*StatefulSet)(nil) + _ Nuker = (*StatefulSet)(nil) + _ Loggable = (*StatefulSet)(nil) + _ Restartable = (*StatefulSet)(nil) + _ Scalable = (*StatefulSet)(nil) + _ Controller = (*StatefulSet)(nil) + _ ContainsPodSpec = (*StatefulSet)(nil) ) // StatefulSet represents a K8s sts. @@ -219,3 +221,39 @@ func (s *StatefulSet) Scan(ctx context.Context, gvr, fqn string, wait bool) (Ref return refs, nil } + +func (s *StatefulSet) GetPodSpec(path string) (*v1.PodSpec, error) { + sts, err := s.getStatefulSet(path) + if err != nil { + return nil, err + } + podSpec := sts.Spec.Template.Spec + return &podSpec, nil +} + +func (s *StatefulSet) SetImages(ctx context.Context, path string, imageSpecs ImageSpecs) error { + ns, n := client.Namespaced(path) + auth, err := s.Client().CanI(ns, "apps/v1/statefulset", []string{client.PatchVerb}) + if err != nil { + return err + } + if !auth { + return fmt.Errorf("user is not authorized to patch a statefulset") + } + jsonPatch, err := GetTemplateJsonPatch(imageSpecs) + if err != nil { + return err + } + dial, err := s.Client().Dial() + if err != nil { + return err + } + _, err = dial.AppsV1().StatefulSets(ns).Patch( + ctx, + n, + types.StrategicMergePatchType, + jsonPatch, + metav1.PatchOptions{}, + ) + return err +} diff --git a/internal/dao/types.go b/internal/dao/types.go index ddf671a6..0f858411 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, imageSpecs ImageSpecs) 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/ds.go b/internal/view/ds.go index 68334e51..587d8049 100644 --- a/internal/view/ds.go +++ b/internal/view/ds.go @@ -17,7 +17,9 @@ func NewDaemonSet(gvr client.GVR) ResourceViewer { d := DaemonSet{ ResourceViewer: NewPortForwardExtender( NewRestartExtender( - NewLogsExtender(NewBrowser(gvr), nil), + NewSetImageExtender( + NewLogsExtender(NewBrowser(gvr), nil), + ), ), ), } diff --git a/internal/view/ds_test.go b/internal/view/ds_test.go index 43d45f8b..0726a3c5 100644 --- a/internal/view/ds_test.go +++ b/internal/view/ds_test.go @@ -13,5 +13,5 @@ func TestDaemonSet(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "DaemonSets", v.Name()) - assert.Equal(t, 13, len(v.Hints())) + assert.Equal(t, 14, len(v.Hints())) } diff --git a/internal/view/help_test.go b/internal/view/help_test.go index dcb07bf1..dda25ddb 100644 --- a/internal/view/help_test.go +++ b/internal/view/help_test.go @@ -21,7 +21,7 @@ func TestHelp(t *testing.T) { v := view.NewHelp() assert.Nil(t, v.Init(ctx)) - assert.Equal(t, 24, v.GetRowCount()) + assert.Equal(t, 25, v.GetRowCount()) assert.Equal(t, 8, v.GetColumnCount()) assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) assert.Equal(t, "Attach", strings.TrimSpace(v.GetCell(1, 1).Text)) diff --git a/internal/view/pod.go b/internal/view/pod.go index c8b12780..1b458cd5 100644 --- a/internal/view/pod.go +++ b/internal/view/pod.go @@ -29,7 +29,9 @@ type Pod struct { func NewPod(gvr client.GVR) ResourceViewer { p := Pod{} p.ResourceViewer = NewPortForwardExtender( - NewLogsExtender(NewBrowser(gvr), p.selectedContainer), + NewSetImageExtender( + NewLogsExtender(NewBrowser(gvr), p.selectedContainer), + ), ) p.SetBindKeysFn(p.bindKeys) p.GetTable().SetEnterFn(p.showContainers) diff --git a/internal/view/pod_test.go b/internal/view/pod_test.go index f33f312b..bf1dd707 100644 --- a/internal/view/pod_test.go +++ b/internal/view/pod_test.go @@ -16,7 +16,7 @@ func TestPodNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "Pods", po.Name()) - assert.Equal(t, 23, len(po.Hints())) + assert.Equal(t, 24, len(po.Hints())) } // Helpers... diff --git a/internal/view/set_image_extender.go b/internal/view/set_image_extender.go new file mode 100644 index 00000000..68561acd --- /dev/null +++ b/internal/view/set_image_extender.go @@ -0,0 +1,171 @@ +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" + "strings" +) + +const setImageKey = "setImage" + +// SetImageExtender adds set image extensions +type SetImageExtender struct { + ResourceViewer +} + +type imageFormSpec struct { + name, dockerImage, newDockerImage string + init bool +} + +func (m *imageFormSpec) modified() bool { + var newDockerImage = strings.TrimSpace(m.newDockerImage) + return newDockerImage != "" && m.dockerImage != newDockerImage +} + +func (m *imageFormSpec) imageSpec() dao.ImageSpec { + var ret = dao.ImageSpec{ + Name: m.name, + Init: m.init, + } + + if m.modified() { + ret.DockerImage = strings.TrimSpace(m.newDockerImage) + } else { + ret.DockerImage = m.dockerImage + } + + return ret +} + +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() + podSpec, err := s.getPodSpec(sel) + if err != nil { + s.App().Flash().Err(err) + return nil + } + var formContainerLines []imageFormSpec + for _, spec := range podSpec.InitContainers { + formContainerLines = append(formContainerLines, imageFormSpec{init: true, name: spec.Name, dockerImage: spec.Image}) + } + for _, spec := range podSpec.Containers { + formContainerLines = append(formContainerLines, imageFormSpec{init: false, name: spec.Name, dockerImage: spec.Image}) + } + for _, ctn := range formContainerLines { + ctnCopy := ctn + f.AddInputField(ctn.name, ctn.dockerImage, 0, nil, func(changed string) { + ctnCopy.newDockerImage = 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() + var imageSpecsModified dao.ImageSpecs + for _, v := range formContainerLines { + if v.modified() { + imageSpecsModified = append(imageSpecsModified, v.imageSpec()) + } + } + + if err := s.setImages(ctx, sel, imageSpecsModified); err != nil { + log.Error().Err(err).Msgf("PodSpec %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) getPodSpec(path string) (*corev1.PodSpec, error) { + res, err := dao.AccessorFor(s.App().factory, s.GVR()) + if err != nil { + return nil, err + } + resourceWPodSpec, ok := res.(dao.ContainsPodSpec) + if !ok { + return nil, fmt.Errorf("expecting a resourceWPodSpec resource for %q", s.GVR()) + } + return resourceWPodSpec.GetPodSpec(path) +} + +func (s *SetImageExtender) setImages(ctx context.Context, path string, imageSpecs dao.ImageSpecs) error { + res, err := dao.AccessorFor(s.App().factory, s.GVR()) + if err != nil { + return err + } + + resourceWPodSpec, ok := res.(dao.ContainsPodSpec) + if !ok { + return fmt.Errorf("expecting a scalable resource for %q", s.GVR()) + } + + return resourceWPodSpec.SetImages(ctx, path, imageSpecs) +} diff --git a/internal/view/sts.go b/internal/view/sts.go index 5a66a78b..6932319d 100644 --- a/internal/view/sts.go +++ b/internal/view/sts.go @@ -21,7 +21,9 @@ func NewStatefulSet(gvr client.GVR) ResourceViewer { ResourceViewer: NewPortForwardExtender( NewRestartExtender( NewScaleExtender( - NewLogsExtender(NewBrowser(gvr), nil), + NewSetImageExtender( + NewLogsExtender(NewBrowser(gvr), nil), + ), ), ), ), diff --git a/internal/view/sts_test.go b/internal/view/sts_test.go index 30a5de22..6b737490 100644 --- a/internal/view/sts_test.go +++ b/internal/view/sts_test.go @@ -13,5 +13,5 @@ func TestStatefulSetNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "StatefulSets", s.Name()) - assert.Equal(t, 11, len(s.Hints())) + assert.Equal(t, 12, len(s.Hints())) }