diff --git a/go.mod b/go.mod index d0e5253a..5d220db2 100644 --- a/go.mod +++ b/go.mod @@ -29,11 +29,11 @@ require ( github.com/stretchr/testify v1.7.0 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f gopkg.in/yaml.v2 v2.4.0 - k8s.io/api v0.0.0-20220824023210-64f80bd511ba - k8s.io/apimachinery v0.0.0-20220805001719-117bd9b56ec3 + k8s.io/api v0.0.0-20220824063211-3be0a3c7fb9e + k8s.io/apimachinery v0.0.0-20220824020058-2b9fe2c31fe2 k8s.io/cli-runtime v0.0.0-20220804203856-b48c51ece852 - k8s.io/client-go v0.0.0-20220824023530-5feaced742a3 - k8s.io/component-base v0.0.0-20220804202306-bd3841ae5bd6 + k8s.io/client-go v0.0.0-20220824023532-d5e58631fd5b + k8s.io/component-base v0.0.0-20220824063641-aebd2342be3e k8s.io/component-helpers v0.0.0-20220824024213-43a709e0c466 k8s.io/klog/v2 v2.70.1 k8s.io/kube-openapi v0.0.0-20220803162953-67bda5d908f1 @@ -94,12 +94,12 @@ require ( ) replace ( - k8s.io/api => k8s.io/api v0.0.0-20220824023210-64f80bd511ba - k8s.io/apimachinery => k8s.io/apimachinery v0.0.0-20220805001719-117bd9b56ec3 + k8s.io/api => k8s.io/api v0.0.0-20220824063211-3be0a3c7fb9e + k8s.io/apimachinery => k8s.io/apimachinery v0.0.0-20220824020058-2b9fe2c31fe2 k8s.io/cli-runtime => k8s.io/cli-runtime v0.0.0-20220804203856-b48c51ece852 - k8s.io/client-go => k8s.io/client-go v0.0.0-20220824023530-5feaced742a3 + k8s.io/client-go => k8s.io/client-go v0.0.0-20220824023532-d5e58631fd5b k8s.io/code-generator => k8s.io/code-generator v0.0.0-20220824022809-a4e23d1b7f08 - k8s.io/component-base => k8s.io/component-base v0.0.0-20220804202306-bd3841ae5bd6 + k8s.io/component-base => k8s.io/component-base v0.0.0-20220824063641-aebd2342be3e k8s.io/component-helpers => k8s.io/component-helpers v0.0.0-20220824024213-43a709e0c466 k8s.io/metrics => k8s.io/metrics v0.0.0-20220804203745-0bf1725b4b86 ) diff --git a/go.sum b/go.sum index 2f6ed4fe..5ca80d5f 100644 --- a/go.sum +++ b/go.sum @@ -541,16 +541,16 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.0.0-20220824023210-64f80bd511ba h1:aTNI+/FBnYaKBP5X6joGb/l0aZEcI7dv7G4d0sDHZWQ= -k8s.io/api v0.0.0-20220824023210-64f80bd511ba/go.mod h1:cuE2+aKfcxEMeHx/NuUKIL3aRJhth7/K9wlCf+3Q3+s= -k8s.io/apimachinery v0.0.0-20220805001719-117bd9b56ec3 h1:Ru2oqar5qMV68dM0G6OEZs2C7qtydpReZ2dHsXpu/Kw= -k8s.io/apimachinery v0.0.0-20220805001719-117bd9b56ec3/go.mod h1:qMx9eAk0sZQGsXGu86fab8tZdffHbwUfsvzqKn4mfB0= +k8s.io/api v0.0.0-20220824063211-3be0a3c7fb9e h1:k0I+usMM7dxoZqtrTe4h6NF1PbU5/395uSP9qTIh5vQ= +k8s.io/api v0.0.0-20220824063211-3be0a3c7fb9e/go.mod h1:JNnSZ8+9vyOvlbOHziuuuudcmvs211TIjt++rqfQ2rk= +k8s.io/apimachinery v0.0.0-20220824020058-2b9fe2c31fe2 h1:n2/BHGLZY5SbYHgcr94lvWRqJTkBb+ByZEZ0gFG8K0c= +k8s.io/apimachinery v0.0.0-20220824020058-2b9fe2c31fe2/go.mod h1:qMx9eAk0sZQGsXGu86fab8tZdffHbwUfsvzqKn4mfB0= k8s.io/cli-runtime v0.0.0-20220804203856-b48c51ece852 h1:jIB0rKV6fdXuN/fttQa5T0JCTHuOaT/1rWhiMccorL4= k8s.io/cli-runtime v0.0.0-20220804203856-b48c51ece852/go.mod h1:Tvpth9pLpTuGtIJRXkHyiRV1aySWB4fkzO/eISsDbk4= -k8s.io/client-go v0.0.0-20220824023530-5feaced742a3 h1:l7kl3+Y+SQOfwP0gZr66o76WJxOfHTwhe5guCzzAlgg= -k8s.io/client-go v0.0.0-20220824023530-5feaced742a3/go.mod h1:g/NYL15K7s+CdNnWuFAbZvlVKCrToqESWFAhB6hi1bE= -k8s.io/component-base v0.0.0-20220804202306-bd3841ae5bd6 h1:FHz479e22/WLD6+Tr3G+YWh5IVaJYocmPjizCb7chDU= -k8s.io/component-base v0.0.0-20220804202306-bd3841ae5bd6/go.mod h1:ij1d8OKrbGbeL3b7tnrEKOuN2itnGAl4CSinffjTRko= +k8s.io/client-go v0.0.0-20220824023532-d5e58631fd5b h1:E3zw7b8nynPUa5umyQixSqsq2XeIusllncmzYXwD1xE= +k8s.io/client-go v0.0.0-20220824023532-d5e58631fd5b/go.mod h1:g/NYL15K7s+CdNnWuFAbZvlVKCrToqESWFAhB6hi1bE= +k8s.io/component-base v0.0.0-20220824063641-aebd2342be3e h1:y4pHR87IyoTEPGCAkF1mLr6rGIyRBdB2HSqztxOmzKA= +k8s.io/component-base v0.0.0-20220824063641-aebd2342be3e/go.mod h1:u6Qkh62unDKneyJ5q8NS97nkAbqMpqGUn+83lrSyakw= k8s.io/component-helpers v0.0.0-20220824024213-43a709e0c466 h1:TlIUI+Yqw0352K1qADGFNHZvgMqCdZIdIQfihAI1bCc= k8s.io/component-helpers v0.0.0-20220824024213-43a709e0c466/go.mod h1:0ojii97LIZp+BuHbhzbvtyrW8bD9Q0DudX8UlOyqR3Y= k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= diff --git a/pkg/cmd/rollout/rollout_history.go b/pkg/cmd/rollout/rollout_history.go index 7e4e0664..65db7726 100644 --- a/pkg/cmd/rollout/rollout_history.go +++ b/pkg/cmd/rollout/rollout_history.go @@ -18,6 +18,7 @@ package rollout import ( "fmt" + "sort" "github.com/spf13/cobra" @@ -153,6 +154,44 @@ func (o *RolloutHistoryOptions) Run() error { return err } + if o.PrintFlags.OutputFlagSpecified() { + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + + return r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + mapping := info.ResourceMapping() + historyViewer, err := o.HistoryViewer(o.RESTClientGetter, mapping) + if err != nil { + return err + } + historyInfo, err := historyViewer.GetHistory(info.Namespace, info.Name) + if err != nil { + return err + } + + if o.Revision > 0 { + printer.PrintObj(historyInfo[o.Revision], o.Out) + } else { + sortedKeys := make([]int64, 0, len(historyInfo)) + for k := range historyInfo { + sortedKeys = append(sortedKeys, k) + } + sort.Slice(sortedKeys, func(i, j int) bool { return sortedKeys[i] < sortedKeys[j] }) + for _, k := range sortedKeys { + printer.PrintObj(historyInfo[k], o.Out) + } + } + + return nil + }) + } + return r.Visit(func(info *resource.Info, err error) error { if err != nil { return err diff --git a/pkg/cmd/rollout/rollout_history_test.go b/pkg/cmd/rollout/rollout_history_test.go new file mode 100644 index 00000000..b2f7b832 --- /dev/null +++ b/pkg/cmd/rollout/rollout_history_test.go @@ -0,0 +1,428 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rollout + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/api/meta" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + cmdtesting "k8s.io/kubectl/pkg/cmd/testing" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" +) + +type fakeHistoryViewer struct { + viewHistoryFn func(namespace, name string, revision int64) (string, error) + getHistoryFn func(namespace, name string) (map[int64]runtime.Object, error) +} + +func (h *fakeHistoryViewer) ViewHistory(namespace, name string, revision int64) (string, error) { + return h.viewHistoryFn(namespace, name, revision) +} + +func (h *fakeHistoryViewer) GetHistory(namespace, name string) (map[int64]runtime.Object, error) { + return h.getHistoryFn(namespace, name) +} + +func setupFakeHistoryViewer(t *testing.T) *fakeHistoryViewer { + fhv := &fakeHistoryViewer{ + viewHistoryFn: func(namespace, name string, revision int64) (string, error) { + t.Fatalf("ViewHistory mock not implemented") + return "", nil + }, + getHistoryFn: func(namespace, name string) (map[int64]runtime.Object, error) { + t.Fatalf("GetHistory mock not implemented") + return nil, nil + }, + } + polymorphichelpers.HistoryViewerFn = func(restClientGetter genericclioptions.RESTClientGetter, mapping *meta.RESTMapping) (polymorphichelpers.HistoryViewer, error) { + return fhv, nil + } + return fhv +} + +func TestRolloutHistory(t *testing.T) { + ns := scheme.Codecs.WithoutConversion() + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + info, _ := runtime.SerializerInfoForMediaType(ns.SupportedMediaTypes(), runtime.ContentTypeJSON) + encoder := ns.EncoderForVersion(info.Serializer, rolloutPauseGroupVersionEncoder) + + tf.Client = &RolloutPauseRESTClient{ + RESTClient: &fake.RESTClient{ + GroupVersion: rolloutPauseGroupVersionEncoder, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/deployments/foo" && m == "GET": + responseDeployment := &appsv1.Deployment{} + responseDeployment.Name = "foo" + body := ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(encoder, responseDeployment)))) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + }, + } + + testCases := map[string]struct { + flags map[string]string + expectedOutput string + expectedRevision int64 + }{ + "should display ViewHistory output for all revisions": { + expectedOutput: `deployment.apps/foo +Fake ViewHistory Output + +`, + expectedRevision: int64(0), + }, + "should display ViewHistory output for a single revision": { + flags: map[string]string{"revision": "2"}, + expectedOutput: `deployment.apps/foo with revision #2 +Fake ViewHistory Output + +`, + expectedRevision: int64(2), + }, + } + + for name, tc := range testCases { + t.Run(name, func(tt *testing.T) { + fhv := setupFakeHistoryViewer(tt) + var actualNamespace, actualName *string + var actualRevision *int64 + fhv.viewHistoryFn = func(namespace, name string, revision int64) (string, error) { + actualNamespace = &namespace + actualName = &name + actualRevision = &revision + return "Fake ViewHistory Output\n", nil + } + + streams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdRolloutHistory(tf, streams) + for k, v := range tc.flags { + cmd.Flags().Set(k, v) + } + cmd.Run(cmd, []string{"deployment/foo"}) + + expectedErrorOutput := "" + if errBuf.String() != expectedErrorOutput { + tt.Fatalf("expected error output: %s, but got %s", expectedErrorOutput, errBuf.String()) + } + + if buf.String() != tc.expectedOutput { + tt.Fatalf("expected output: %s, but got: %s", tc.expectedOutput, buf.String()) + } + + expectedNamespace := "test" + if actualNamespace == nil || *actualNamespace != expectedNamespace { + tt.Fatalf("expected ViewHistory to have been called with namespace %s, but it was %v", expectedNamespace, *actualNamespace) + } + + expectedName := "foo" + if actualName == nil || *actualName != expectedName { + tt.Fatalf("expected ViewHistory to have been called with name %s, but it was %v", expectedName, *actualName) + } + + if actualRevision == nil { + tt.Fatalf("expected ViewHistory to have been called with revision %d, but it was ", tc.expectedRevision) + } else if *actualRevision != tc.expectedRevision { + tt.Fatalf("expected ViewHistory to have been called with revision %d, but it was %v", tc.expectedRevision, *actualRevision) + } + }) + } +} + +func TestMultipleResourceRolloutHistory(t *testing.T) { + ns := scheme.Codecs.WithoutConversion() + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + info, _ := runtime.SerializerInfoForMediaType(ns.SupportedMediaTypes(), runtime.ContentTypeJSON) + encoder := ns.EncoderForVersion(info.Serializer, rolloutPauseGroupVersionEncoder) + + tf.Client = &RolloutPauseRESTClient{ + RESTClient: &fake.RESTClient{ + GroupVersion: rolloutPauseGroupVersionEncoder, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/deployments/foo" && m == "GET": + responseDeployment := &appsv1.Deployment{} + responseDeployment.Name = "foo" + body := ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(encoder, responseDeployment)))) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: body}, nil + case p == "/namespaces/test/deployments/bar" && m == "GET": + responseDeployment := &appsv1.Deployment{} + responseDeployment.Name = "bar" + body := ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(encoder, responseDeployment)))) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + }, + } + + testCases := map[string]struct { + flags map[string]string + expectedOutput string + }{ + "should display ViewHistory output for all revisions": { + expectedOutput: `deployment.apps/foo +Fake ViewHistory Output + +deployment.apps/bar +Fake ViewHistory Output + +`, + }, + "should display ViewHistory output for a single revision": { + flags: map[string]string{"revision": "2"}, + expectedOutput: `deployment.apps/foo with revision #2 +Fake ViewHistory Output + +deployment.apps/bar with revision #2 +Fake ViewHistory Output + +`, + }, + } + + for name, tc := range testCases { + t.Run(name, func(tt *testing.T) { + fhv := setupFakeHistoryViewer(tt) + fhv.viewHistoryFn = func(namespace, name string, revision int64) (string, error) { + return "Fake ViewHistory Output\n", nil + } + + streams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdRolloutHistory(tf, streams) + for k, v := range tc.flags { + cmd.Flags().Set(k, v) + } + cmd.Run(cmd, []string{"deployment/foo", "deployment/bar"}) + + expectedErrorOutput := "" + if errBuf.String() != expectedErrorOutput { + tt.Fatalf("expected error output: %s, but got %s", expectedErrorOutput, errBuf.String()) + } + + if buf.String() != tc.expectedOutput { + tt.Fatalf("expected output: %s, but got: %s", tc.expectedOutput, buf.String()) + } + }) + } +} + +func TestRolloutHistoryWithOutput(t *testing.T) { + ns := scheme.Codecs.WithoutConversion() + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + info, _ := runtime.SerializerInfoForMediaType(ns.SupportedMediaTypes(), runtime.ContentTypeJSON) + encoder := ns.EncoderForVersion(info.Serializer, rolloutPauseGroupVersionEncoder) + + tf.Client = &RolloutPauseRESTClient{ + RESTClient: &fake.RESTClient{ + GroupVersion: rolloutPauseGroupVersionEncoder, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/deployments/foo" && m == "GET": + responseDeployment := &appsv1.Deployment{} + responseDeployment.Name = "foo" + body := ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(encoder, responseDeployment)))) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + }, + } + + testCases := map[string]struct { + flags map[string]string + expectedOutput string + }{ + "json": { + flags: map[string]string{"revision": "2", "output": "json"}, + expectedOutput: `{ + "kind": "ReplicaSet", + "apiVersion": "apps/v1", + "metadata": { + "name": "rev2", + "creationTimestamp": null + }, + "spec": { + "selector": null, + "template": { + "metadata": { + "creationTimestamp": null + }, + "spec": { + "containers": null + } + } + }, + "status": { + "replicas": 0 + } +} +`, + }, + "yaml": { + flags: map[string]string{"revision": "2", "output": "yaml"}, + expectedOutput: `apiVersion: apps/v1 +kind: ReplicaSet +metadata: + creationTimestamp: null + name: rev2 +spec: + selector: null + template: + metadata: + creationTimestamp: null + spec: + containers: null +status: + replicas: 0 +`, + }, + "yaml all revisions": { + flags: map[string]string{"output": "yaml"}, + expectedOutput: `apiVersion: apps/v1 +kind: ReplicaSet +metadata: + creationTimestamp: null + name: rev1 +spec: + selector: null + template: + metadata: + creationTimestamp: null + spec: + containers: null +status: + replicas: 0 +--- +apiVersion: apps/v1 +kind: ReplicaSet +metadata: + creationTimestamp: null + name: rev2 +spec: + selector: null + template: + metadata: + creationTimestamp: null + spec: + containers: null +status: + replicas: 0 +`, + }, + "name": { + flags: map[string]string{"output": "name"}, + expectedOutput: `replicaset.apps/rev1 +replicaset.apps/rev2 +`, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + fhv := setupFakeHistoryViewer(t) + var actualNamespace, actualName *string + fhv.getHistoryFn = func(namespace, name string) (map[int64]runtime.Object, error) { + actualNamespace = &namespace + actualName = &name + return map[int64]runtime.Object{ + 1: &appsv1.ReplicaSet{ObjectMeta: v1.ObjectMeta{Name: "rev1"}}, + 2: &appsv1.ReplicaSet{ObjectMeta: v1.ObjectMeta{Name: "rev2"}}, + }, nil + } + + streams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdRolloutHistory(tf, streams) + for k, v := range tc.flags { + cmd.Flags().Set(k, v) + } + cmd.Run(cmd, []string{"deployment/foo"}) + + expectedErrorOutput := "" + if errBuf.String() != expectedErrorOutput { + t.Fatalf("expected error output: %s, but got %s", expectedErrorOutput, errBuf.String()) + } + + if buf.String() != tc.expectedOutput { + t.Fatalf("expected output: %s, but got: %s", tc.expectedOutput, buf.String()) + } + + expectedNamespace := "test" + if actualNamespace == nil || *actualNamespace != expectedNamespace { + t.Fatalf("expected GetHistory to have been called with namespace %s, but it was %v", expectedNamespace, *actualNamespace) + } + + expectedName := "foo" + if actualName == nil || *actualName != expectedName { + t.Fatalf("expected GetHistory to have been called with name %s, but it was %v", expectedName, *actualName) + } + }) + } +} + +func TestValidate(t *testing.T) { + opts := RolloutHistoryOptions{ + Revision: 0, + Resources: []string{"deployment/foo"}, + } + if err := opts.Validate(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + opts.Revision = -1 + expectedError := "revision must be a positive integer: -1" + if err := opts.Validate(); err == nil { + t.Fatalf("unexpected non error") + } else if err.Error() != expectedError { + t.Fatalf("expected error %s, but got %s", expectedError, err.Error()) + } + + opts.Revision = 0 + opts.Resources = []string{} + expectedError = "required resource not specified" + if err := opts.Validate(); err == nil { + t.Fatalf("unexpected non error") + } else if err.Error() != expectedError { + t.Fatalf("expected error %s, but got %s", expectedError, err.Error()) + } +} diff --git a/pkg/polymorphichelpers/history.go b/pkg/polymorphichelpers/history.go index 139cc8f8..20afbe98 100644 --- a/pkg/polymorphichelpers/history.go +++ b/pkg/polymorphichelpers/history.go @@ -25,7 +25,6 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -35,6 +34,7 @@ import ( "k8s.io/apimachinery/pkg/util/strategicpatch" "k8s.io/client-go/kubernetes" clientappsv1 "k8s.io/client-go/kubernetes/typed/apps/v1" + "k8s.io/klog/v2" "k8s.io/kubectl/pkg/apps" "k8s.io/kubectl/pkg/describe" deploymentutil "k8s.io/kubectl/pkg/util/deployment" @@ -48,6 +48,7 @@ const ( // HistoryViewer provides an interface for resources have historical information. type HistoryViewer interface { ViewHistory(namespace, name string, revision int64) (string, error) + GetHistory(namespace, name string) (map[int64]runtime.Object, error) } type HistoryVisitor struct { @@ -101,24 +102,16 @@ type DeploymentHistoryViewer struct { // ViewHistory returns a revision-to-replicaset map as the revision history of a deployment // TODO: this should be a describer func (h *DeploymentHistoryViewer) ViewHistory(namespace, name string, revision int64) (string, error) { - versionedAppsClient := h.c.AppsV1() - deployment, err := versionedAppsClient.Deployments(namespace).Get(context.TODO(), name, metav1.GetOptions{}) + allRSs, err := getDeploymentReplicaSets(h.c.AppsV1(), namespace, name) if err != nil { - return "", fmt.Errorf("failed to retrieve deployment %s: %v", name, err) - } - _, allOldRSs, newRS, err := deploymentutil.GetAllReplicaSets(deployment, versionedAppsClient) - if err != nil { - return "", fmt.Errorf("failed to retrieve replica sets from deployment %s: %v", name, err) - } - allRSs := allOldRSs - if newRS != nil { - allRSs = append(allRSs, newRS) + return "", err } historyInfo := make(map[int64]*corev1.PodTemplateSpec) for _, rs := range allRSs { v, err := deploymentutil.Revision(rs) if err != nil { + klog.Warningf("unable to get revision from replicaset %s for deployment %s in namespace %s: %v", rs.Name, name, namespace, err) continue } historyInfo[v] = &rs.Spec.Template @@ -165,6 +158,26 @@ func (h *DeploymentHistoryViewer) ViewHistory(namespace, name string, revision i }) } +// GetHistory returns the ReplicaSet revisions associated with a Deployment +func (h *DeploymentHistoryViewer) GetHistory(namespace, name string) (map[int64]runtime.Object, error) { + allRSs, err := getDeploymentReplicaSets(h.c.AppsV1(), namespace, name) + if err != nil { + return nil, err + } + + result := make(map[int64]runtime.Object) + for _, rs := range allRSs { + v, err := deploymentutil.Revision(rs) + if err != nil { + klog.Warningf("unable to get revision from replicaset %s for deployment %s in namespace %s: %v", rs.Name, name, namespace, err) + continue + } + result[v] = rs + } + + return result, nil +} + func printTemplate(template *corev1.PodTemplateSpec) (string, error) { buf := bytes.NewBuffer([]byte{}) w := describe.NewPrefixWriter(buf) @@ -192,6 +205,25 @@ func (h *DaemonSetHistoryViewer) ViewHistory(namespace, name string, revision in }) } +// GetHistory returns the revisions associated with a DaemonSet +func (h *DaemonSetHistoryViewer) GetHistory(namespace, name string) (map[int64]runtime.Object, error) { + ds, history, err := daemonSetHistory(h.c.AppsV1(), namespace, name) + if err != nil { + return nil, err + } + + result := make(map[int64]runtime.Object) + for _, h := range history { + applied, err := applyDaemonSetHistory(ds, h) + if err != nil { + return nil, err + } + result[h.Revision] = applied + } + + return result, nil +} + // printHistory returns the podTemplate of the given revision if it is non-zero // else returns the overall revisions func printHistory(history []*appsv1.ControllerRevision, revision int64, getPodTemplate func(history *appsv1.ControllerRevision) (*corev1.PodTemplateSpec, error)) (string, error) { @@ -259,6 +291,42 @@ func (h *StatefulSetHistoryViewer) ViewHistory(namespace, name string, revision }) } +// GetHistory returns the revisions associated with a StatefulSet +func (h *StatefulSetHistoryViewer) GetHistory(namespace, name string) (map[int64]runtime.Object, error) { + sts, history, err := statefulSetHistory(h.c.AppsV1(), namespace, name) + if err != nil { + return nil, err + } + + result := make(map[int64]runtime.Object) + for _, h := range history { + applied, err := applyStatefulSetHistory(sts, h) + if err != nil { + return nil, err + } + result[h.Revision] = applied + } + + return result, nil +} + +func getDeploymentReplicaSets(apps clientappsv1.AppsV1Interface, namespace, name string) ([]*appsv1.ReplicaSet, error) { + deployment, err := apps.Deployments(namespace).Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to retrieve deployment %s: %v", name, err) + } + + _, oldRSs, newRS, err := deploymentutil.GetAllReplicaSets(deployment, apps) + if err != nil { + return nil, fmt.Errorf("failed to retrieve replica sets from deployment %s: %v", name, err) + } + + if newRS == nil { + return oldRSs, nil + } + return append(oldRSs, newRS), nil +} + // controlledHistories returns all ControllerRevisions in namespace that selected by selector and owned by accessor // TODO: Rename this to controllerHistory when other controllers have been upgraded func controlledHistoryV1( diff --git a/pkg/polymorphichelpers/history_test.go b/pkg/polymorphichelpers/history_test.go index 13b204c6..cb77ecdb 100644 --- a/pkg/polymorphichelpers/history_test.go +++ b/pkg/polymorphichelpers/history_test.go @@ -18,6 +18,7 @@ package polymorphichelpers import ( "context" + "fmt" "reflect" "testing" @@ -27,6 +28,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/json" "k8s.io/client-go/kubernetes/fake" ) @@ -52,6 +54,140 @@ func TestHistoryViewerFor(t *testing.T) { } } +func TestViewDeploymentHistory(t *testing.T) { + trueVar := true + replicas := int32(1) + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "moons", + Namespace: "default", + UID: "fc7e66ad-eacc-4413-8277-e22276eacce6", + Labels: map[string]string{"foo": "bar"}, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + Replicas: &replicas, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"foo": "bar"}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "test", + Image: fmt.Sprintf("foo:1"), + }}}, + }, + }, + } + + fakeClientSet := fake.NewSimpleClientset(deployment) + + replicaSets := map[int64]*appsv1.ReplicaSet{} + var i int64 + for i = 1; i < 5; i++ { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("moons-%d", i), + Namespace: "default", + UID: types.UID(fmt.Sprintf("00000000-0000-0000-0000-00000000000%d", i)), + Labels: map[string]string{"foo": "bar"}, + OwnerReferences: []metav1.OwnerReference{{"apps/v1", "Deployment", deployment.Name, deployment.UID, &trueVar, nil}}, + Annotations: map[string]string{ + "deployment.kubernetes.io/revision": fmt.Sprintf("%d", i), + }, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + Replicas: &replicas, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"foo": "bar"}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "test", + Image: fmt.Sprintf("foo:%d", i), + }}}, + }, + }, + } + + if i == 3 { + rs.ObjectMeta.Annotations[ChangeCauseAnnotation] = "foo change cause" + } else if i == 4 { + rs.ObjectMeta.Annotations[ChangeCauseAnnotation] = "bar change cause" + } + + fakeClientSet.AppsV1().ReplicaSets("default").Create(context.TODO(), rs, metav1.CreateOptions{}) + replicaSets[i] = rs + } + + viewer := DeploymentHistoryViewer{fakeClientSet} + + t.Run("should show revisions list if the revision is not specified", func(t *testing.T) { + result, err := viewer.ViewHistory("default", "moons", 0) + if err != nil { + t.Fatalf("error getting history for Deployment moons: %v", err) + } + + expected := `REVISION CHANGE-CAUSE +1 +2 +3 foo change cause +4 bar change cause +` + if result != expected { + t.Fatalf("unexpected output (%v was expected but got %v)", expected, result) + } + }) + + t.Run("should describe a single revision", func(t *testing.T) { + result, err := viewer.ViewHistory("default", "moons", 3) + if err != nil { + t.Fatalf("error getting history for Deployment moons: %v", err) + } + + expected := `Pod Template: + Labels: foo=bar + Annotations: kubernetes.io/change-cause: foo change cause + Containers: + test: + Image: foo:3 + Port: + Host Port: + Environment: + Mounts: + Volumes: +` + if result != expected { + t.Fatalf("unexpected output (%v was expected but got %v)", expected, result) + } + }) + + t.Run("should get history", func(t *testing.T) { + result, err := viewer.GetHistory("default", "moons") + if err != nil { + t.Fatalf("error getting history for Deployment moons: %v", err) + } + + if len(result) != 4 { + t.Fatalf("unexpected history length (expected 4, got %d", len(result)) + } + + for i = 1; i < 4; i++ { + actual, found := result[i] + if !found { + t.Fatalf("revision %d not found in history", i) + } + expected := replicaSets[i] + if !reflect.DeepEqual(expected, actual) { + t.Errorf("history does not match. expected %+v, got %+v", expected, actual) + } + } + }) +} + func TestViewHistory(t *testing.T) { t.Run("for statefulSet", func(t *testing.T) { @@ -138,6 +274,25 @@ func TestViewHistory(t *testing.T) { } }) + t.Run("should get history", func(t *testing.T) { + result, err := sts.GetHistory("default", "moons") + if err != nil { + t.Fatalf("error getting history for StatefulSet moons: %v", err) + } + + if len(result) != 1 { + t.Fatalf("unexpected history length (expected 1, got %d", len(result)) + } + + actual, found := result[1] + if !found { + t.Fatalf("revision 1 not found in history") + } + expected := ssStub + if !reflect.DeepEqual(expected, actual) { + t.Errorf("history does not match. expected %+v, got %+v", expected, actual) + } + }) }) t.Run("for daemonSet", func(t *testing.T) { @@ -188,7 +343,7 @@ func TestViewHistory(t *testing.T) { t.Run("should show revisions list if the revision is not specified", func(t *testing.T) { result, err := daemonSetHistoryViewer.ViewHistory("default", "moons", 0) if err != nil { - t.Fatalf("error getting ViewHistory for a StatefulSets moons: %v", err) + t.Fatalf("error getting ViewHistory for DaemonSet moons: %v", err) } expected := `REVISION CHANGE-CAUSE @@ -203,7 +358,7 @@ func TestViewHistory(t *testing.T) { t.Run("should describe the revision if revision is specified", func(t *testing.T) { result, err := daemonSetHistoryViewer.ViewHistory("default", "moons", 1) if err != nil { - t.Fatalf("error getting ViewHistory for a StatefulSets moons: %v", err) + t.Fatalf("error getting ViewHistory for DaemonSet moons: %v", err) } expected := `Pod Template: @@ -223,6 +378,25 @@ func TestViewHistory(t *testing.T) { } }) + t.Run("should get history", func(t *testing.T) { + result, err := daemonSetHistoryViewer.GetHistory("default", "moons") + if err != nil { + t.Fatalf("error getting history for DaemonSet moons: %v", err) + } + + if len(result) != 1 { + t.Fatalf("unexpected history length (expected 1, got %d", len(result)) + } + + actual, found := result[1] + if !found { + t.Fatalf("revision 1 not found in history") + } + expected := daemonSetStub + if !reflect.DeepEqual(expected, actual) { + t.Errorf("history does not match. expected %+v, got %+v", expected, actual) + } + }) }) }