From dec4d5b0e3f98045835aa5a023e36c2bd73e569f Mon Sep 17 00:00:00 2001 From: Bhargav Ravuri Date: Sun, 3 Nov 2024 06:29:33 +0530 Subject: [PATCH] test(get deployed): Add pkg/action level tests Add pkg/action level tests related to `helm get deployed` command. Related to #12722 Signed-off-by: Bhargav Ravuri --- pkg/action/get_deployed.go | 14 +- pkg/action/get_deployed_test.go | 557 ++++++++++++++++++++++++++++++++ pkg/kube/fake/printer.go | 64 ++-- 3 files changed, 609 insertions(+), 26 deletions(-) create mode 100644 pkg/action/get_deployed_test.go diff --git a/pkg/action/get_deployed.go b/pkg/action/get_deployed.go index 4b60390c8b0..336e6941cea 100644 --- a/pkg/action/get_deployed.go +++ b/pkg/action/get_deployed.go @@ -115,7 +115,7 @@ func (g *GetDeployed) processResourceRecord(manifest *yaml.RNode, mapper meta.RE // Build resource list required for Helm kube client filter, err := g.cfg.KubeClient.Build(bytes.NewBufferString(manifestStr), false) if err != nil { - return nil, fmt.Errorf("failed to build resource list: %v", err) + return nil, fmt.Errorf("failed to build resource list: %w", err) } // Fetch the resources from the Kubernetes cluster based on the resource list built above @@ -124,12 +124,12 @@ func (g *GetDeployed) processResourceRecord(manifest *yaml.RNode, mapper meta.RE // the current record. list, err := g.cfg.KubeClient.Get(filter, false) if err != nil { - return nil, fmt.Errorf("failed to get the resource from cluster: %v", err) + return nil, fmt.Errorf("failed to get the resource from cluster: %w", err) } var ( resourceObj runtime.Object - metaObj metav1.Object + objMeta metav1.Object ) // Extract the resource object and its metadata from the list of resources. Note: Though Get() returns a list of @@ -139,12 +139,12 @@ func (g *GetDeployed) processResourceRecord(manifest *yaml.RNode, mapper meta.RE var ok bool for _, objects := range list { for _, obj := range objects { - metaObj, ok = obj.(metav1.Object) + objMeta, ok = obj.(metav1.Object) if !ok { return fmt.Errorf("object does not implement metav1.Object interface") } - if metaObj.GetName() != manifest.GetName() { + if objMeta.GetName() != manifest.GetName() { continue } @@ -170,9 +170,9 @@ func (g *GetDeployed) processResourceRecord(manifest *yaml.RNode, mapper meta.RE return &ResourceElement{ Resource: resourceMapping.Resource.Resource, Name: manifest.GetName(), - Namespace: metaObj.GetNamespace(), + Namespace: objMeta.GetNamespace(), APIVersion: manifest.GetApiVersion(), - CreationTimestamp: metaObj.GetCreationTimestamp(), + CreationTimestamp: objMeta.GetCreationTimestamp(), }, nil } diff --git a/pkg/action/get_deployed_test.go b/pkg/action/get_deployed_test.go new file mode 100644 index 00000000000..c5e99a362be --- /dev/null +++ b/pkg/action/get_deployed_test.go @@ -0,0 +1,557 @@ +/* +Copyright The Helm 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 action + +import ( + "bytes" + "fmt" + "io" + "testing" + texttemplate "text/template" + "time" + + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/release" + helmtime "helm.sh/helm/v3/pkg/time" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metatable "k8s.io/apimachinery/pkg/api/meta/table" + "k8s.io/apimachinery/pkg/api/meta/testrestmapper" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +const ( + manifestTemplate = `--- +# Source: templates/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +--- +# Source: templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: nami + namespace: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +data: + attack: "Gomu Gomu no King Kong Gun!" +--- +# Source: templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: zoro + namespace: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +spec: + type: ClusterIP + selector: + app: one-piece + ports: + - protocol: TCP + port: 80 + targetPort: 80 +--- +# Source: templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: luffy + namespace: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +spec: + replicas: 2 + selector: + matchLabels: + app: one-piece + template: + metadata: + labels: + app: one-piece + spec: + containers: + - name: luffy-arsenal + image: "nginx:1.21.6" + ports: + - containerPort: 80 + env: + - name: ATTACK + valueFrom: + configMapKeyRef: + name: luffy + key: attack +` + tableOutputTemplate = `NAMESPACE NAME API_VERSION AGE + namespaces/{{ .Namespace }} v1 {{ .Age }} +{{ .Namespace }} configmaps/nami v1 {{ .Age }} +{{ .Namespace }} services/zoro v1 {{ .Age }} +{{ .Namespace }} deployments/luffy apps/v1 {{ .Age }} +` + jsonOutputTemplate = `[{` + + `"name":"{{ .Namespace }}",` + + `"namespace":"",` + + `"apiVersion":"v1",` + + `"resource":"namespaces",` + + `"creationTimestamp":"{{ .CreationTimestamp }}"},{"name":"nami",` + + `"namespace":"{{ .Namespace }}",` + + `"apiVersion":"v1",` + + `"resource":"configmaps",` + + `"creationTimestamp":"{{ .CreationTimestamp }}"},{"name":"zoro",` + + `"namespace":"{{ .Namespace }}",` + + `"apiVersion":"v1",` + + `"resource":"services",` + + `"creationTimestamp":"{{ .CreationTimestamp }}"},{"name":"luffy",` + + `"namespace":"{{ .Namespace }}",` + + `"apiVersion":"apps/v1",` + + `"resource":"deployments",` + + `"creationTimestamp":"{{ .CreationTimestamp }}"` + + `}] +` + yamlOutputTemplate = `- apiVersion: v1 + creationTimestamp: "{{ .CreationTimestamp }}" + name: {{ .Namespace }} + namespace: "" + resource: namespaces +- apiVersion: v1 + creationTimestamp: "{{ .CreationTimestamp }}" + name: nami + namespace: {{ .Namespace }} + resource: configmaps +- apiVersion: v1 + creationTimestamp: "{{ .CreationTimestamp }}" + name: zoro + namespace: {{ .Namespace }} + resource: services +- apiVersion: apps/v1 + creationTimestamp: "{{ .CreationTimestamp }}" + name: luffy + namespace: {{ .Namespace }} + resource: deployments +` +) + +type getDeployedOutputData struct { + Namespace string + CreationTimestamp string + Age string +} + +func TestGetDeployed(t *testing.T) { + var ( + is = assert.New(t) + chartName = `one-piece` + namespace = `thousand-sunny` + exactTimestamp = time.Date(2024, time.October, 28, 0, 4, 30, 0, time.FixedZone("IST", 19800)).UTC() + relativeTimestamp = time.Now().Add(-2 * time.Minute).UTC() + ) + + type ( + testFunc struct { + writeTable bool + writeJSON bool + writeYAML bool + } + + testCase struct { + name string + creationTimestamp time.Time + testFunc testFunc + } + ) + + tests := []testCase{ + { + name: "With Exact Creation Time", + creationTimestamp: exactTimestamp, + testFunc: testFunc{ + writeTable: false, + writeJSON: true, + writeYAML: true, + }, + }, + { + name: "With Relative Creation Time", + creationTimestamp: relativeTimestamp, + testFunc: testFunc{ + writeTable: true, + writeJSON: false, + writeYAML: false, + }, + }, + } + + scheme := runtime.NewScheme() + is.NoError(corev1.AddToScheme(scheme)) + is.NoError(appsv1.AddToScheme(scheme)) + restMapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(restMapper) + + formatResourceList := func(creationTimestamp time.Time) []ResourceElement { + creationTimestampStr := creationTimestamp.Format(time.RFC3339) + + manifest, err := parseGetDeployedTestTemplate(namespace, creationTimestampStr, "", manifestTemplate) + is.NoError(err) + + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + } + config.RESTClientGetter = configFlags + + client := NewGetDeployed(config) + releases := []*release.Release{ + { + Name: chartName, + Info: &release.Info{ + LastDeployed: helmtime.Unix(creationTimestamp.Unix(), 0), + Status: release.StatusDeployed, + }, + Manifest: manifest.String(), + Namespace: namespace, + }, + } + + for _, rel := range releases { + err = client.cfg.Releases.Create(rel) + is.NoError(err) + } + + resourceList, err := client.Run(chartName) + is.NoError(err) + + return resourceList + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + is := assert.New(t) + resourceList := formatResourceList(tc.creationTimestamp) + is.NotEmpty(resourceList) + + writer := NewResourceListWriter(resourceList, false) + creationTimestampStr := tc.creationTimestamp.Format(time.RFC3339) + creationTimestampAgeStr := metatable.ConvertToHumanReadableDateType(metav1.NewTime(tc.creationTimestamp)) + + var ( + out bytes.Buffer + expectedOut fmt.Stringer + err error + ) + + if tc.testFunc.writeTable { + t.Run("Write Table", func(t *testing.T) { + is := assert.New(t) + out.Truncate(0) + + expectedOut, err = parseGetDeployedTestTemplate( + namespace, + "", // Creation timestamp is not used in table output, but creation timestamp's age + creationTimestampAgeStr, + tableOutputTemplate, + ) + is.NoError(err) + + err = writer.WriteTable(&out) + is.NoError(err) + is.Equal(expectedOut.String(), out.String()) + }) + } + + if tc.testFunc.writeJSON { + t.Run("Write JSON", func(t *testing.T) { + is := assert.New(t) + out.Truncate(0) + + expectedOut, err = parseGetDeployedTestTemplate( + namespace, + creationTimestampStr, + "", // Creation timestamp's age is not used in JSON output, but the creation timestamp itself + jsonOutputTemplate, + ) + is.NoError(err) + + err = writer.WriteJSON(&out) + is.NoError(err) + is.Equal(expectedOut.String(), out.String()) + }) + } + + if tc.testFunc.writeYAML { + t.Run("Write YAML", func(t *testing.T) { + is := assert.New(t) + out.Truncate(0) + + expectedOut, err = parseGetDeployedTestTemplate( + namespace, + creationTimestampStr, + "", // Creation timestamp's age is not used in YAML output, but the creation timestamp itself + yamlOutputTemplate, + ) + is.NoError(err) + + err = writer.WriteYAML(&out) + is.NoError(err) + is.Equal(expectedOut.String(), out.String()) + }) + } + }) + + } +} + +func parseGetDeployedTestTemplate(namespace, creationTimestamp, age, template string) (fmt.Stringer, error) { + outputParser, err := texttemplate.New("template").Parse(template) + if err != nil { + return nil, err + } + + var out bytes.Buffer + err = outputParser.Execute(&out, getDeployedOutputData{ + Namespace: namespace, + CreationTimestamp: creationTimestamp, + Age: age, + }) + if err != nil { + return nil, err + } + + return &out, nil +} + +func TestGetDeployed_ErrorKubeClientNotReachable(t *testing.T) { + is := assert.New(t) + chartName := `one-piece` + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + IsReachableReturnsError: true, + }, + } + + client := NewGetDeployed(config) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.ErrorIs(err, kubefake.ErrPrintingKubeClientNotReachable) +} + +func TestGetDeployed_ErrorReleaseNotFound(t *testing.T) { + is := assert.New(t) + chartName := `one-piece` + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + IsReachableReturnsError: false, + }, + } + + client := NewGetDeployed(config) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.Contains(err.Error(), "release: not found") +} + +func TestGetDeployed_RESTMapperNotFound(t *testing.T) { + var ( + is = assert.New(t) + chartName = `one-piece` + ) + + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(nil) + + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + } + config.RESTClientGetter = configFlags + + client := NewGetDeployed(config) + + err := client.cfg.Releases.Create(&release.Release{ + Name: chartName, + Info: &release.Info{}, + }) + is.NoError(err) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.Contains(err.Error(), "failed to extract the REST mapper: no restmapper") +} + +func TestGetDeployed_ResourceListBuildFailure(t *testing.T) { + var ( + is = assert.New(t) + chartName = `one-piece` + namespace = `thousand-sunny` + exactTimestamp = time.Date(2024, time.October, 28, 0, 4, 30, 0, time.FixedZone("IST", 19800)).UTC() + ) + + scheme := runtime.NewScheme() + is.NoError(corev1.AddToScheme(scheme)) + is.NoError(appsv1.AddToScheme(scheme)) + restMapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(restMapper) + + creationTimestampStr := exactTimestamp.Format(time.RFC3339) + manifest, err := parseGetDeployedTestTemplate(namespace, creationTimestampStr, "", manifestTemplate) + is.NoError(err) + + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + BuildReturnError: true, + }, + } + config.RESTClientGetter = configFlags + + client := NewGetDeployed(config) + + err = client.cfg.Releases.Create(&release.Release{ + Name: chartName, + Info: &release.Info{ + LastDeployed: helmtime.Unix(exactTimestamp.Unix(), 0), + Status: release.StatusDeployed, + }, + Manifest: manifest.String(), + Namespace: namespace, + }) + is.NoError(err) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.ErrorIs(err, kubefake.ErrPrintingKubeClientBuildFailure) +} + +func TestGetDeployed_GetResourceFailure(t *testing.T) { + var ( + is = assert.New(t) + chartName = `one-piece` + namespace = `thousand-sunny` + exactTimestamp = time.Date(2024, time.October, 28, 0, 4, 30, 0, time.FixedZone("IST", 19800)).UTC() + ) + + scheme := runtime.NewScheme() + is.NoError(corev1.AddToScheme(scheme)) + is.NoError(appsv1.AddToScheme(scheme)) + restMapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(restMapper) + + creationTimestampStr := exactTimestamp.Format(time.RFC3339) + manifest, err := parseGetDeployedTestTemplate(namespace, creationTimestampStr, "", manifestTemplate) + is.NoError(err) + + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + GetReturnError: true, + }, + } + config.RESTClientGetter = configFlags + + client := NewGetDeployed(config) + + err = client.cfg.Releases.Create(&release.Release{ + Name: chartName, + Info: &release.Info{ + LastDeployed: helmtime.Unix(exactTimestamp.Unix(), 0), + Status: release.StatusDeployed, + }, + Manifest: manifest.String(), + Namespace: namespace, + }) + is.NoError(err) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.ErrorIs(err, kubefake.ErrPrintingKubeClientGetFailure) +} + +func TestGetDeployed_MissingGVK(t *testing.T) { + var ( + is = assert.New(t) + chartName = `one-piece` + namespace = `thousand-sunny` + exactTimestamp = time.Date(2024, time.October, 28, 0, 4, 30, 0, time.FixedZone("IST", 19800)).UTC() + ) + + scheme := runtime.NewScheme() + is.NoError(corev1.AddToScheme(scheme)) + restMapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(restMapper) + + creationTimestampStr := exactTimestamp.Format(time.RFC3339) + + manifest, err := parseGetDeployedTestTemplate(namespace, creationTimestampStr, "", manifestTemplate) + is.NoError(err) + + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + } + config.RESTClientGetter = configFlags + + client := NewGetDeployed(config) + err = client.cfg.Releases.Create(&release.Release{ + Name: chartName, + Info: &release.Info{ + LastDeployed: helmtime.Unix(exactTimestamp.Unix(), 0), + Status: release.StatusDeployed, + }, + Manifest: manifest.String(), + Namespace: namespace, + }) + is.NoError(err) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.Contains(err.Error(), "no matches for kind \"Deployment\" in version \"apps/v1\"") +} diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go index 338f13491c0..3c1f8292874 100644 --- a/pkg/kube/fake/printer.go +++ b/pkg/kube/fake/printer.go @@ -17,6 +17,7 @@ limitations under the License. package fake import ( + "errors" "fmt" "io" "strings" @@ -39,7 +40,10 @@ import ( // Options to control the fake behavior of PrintingKubeClient type Options struct { GetReturnResourceMap bool + GetReturnError bool BuildReturnResourceList bool + BuildReturnError bool + IsReachableReturnsError bool } // PrintingKubeClient implements KubeClient, but simply prints the reader to @@ -49,8 +53,18 @@ type PrintingKubeClient struct { Options *Options } +var ( + ErrPrintingKubeClientNotReachable error = errors.New("kubernetes cluster not reachable") + ErrPrintingKubeClientBuildFailure error = errors.New("failed to build resource list") + ErrPrintingKubeClientGetFailure error = errors.New("failed to get resource") +) + // IsReachable checks if the cluster is reachable func (p *PrintingKubeClient) IsReachable() error { + if p.Options != nil && p.Options.IsReachableReturnsError { + return ErrPrintingKubeClientNotReachable + } + return nil } @@ -69,18 +83,24 @@ func (p *PrintingKubeClient) Get(resources kube.ResourceList, _ bool) (map[strin return nil, err } - if p.Options == nil || !p.Options.GetReturnResourceMap { - return make(map[string][]runtime.Object), nil - } + if p.Options != nil { + if p.Options.GetReturnError { + return nil, ErrPrintingKubeClientGetFailure + } + + if p.Options.GetReturnResourceMap { + result := make(map[string][]runtime.Object) + for _, r := range resources { + result[r.Name] = []runtime.Object{ + r.Object, + } + } - result := make(map[string][]runtime.Object) - for _, r := range resources { - result[r.Name] = []runtime.Object{ - r.Object, + return result, nil } } - return result, nil + return make(map[string][]runtime.Object), nil } func (p *PrintingKubeClient) Wait(resources kube.ResourceList, _ time.Duration) error { @@ -129,21 +149,27 @@ func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, _ bool) (*kub // Build implements KubeClient Build. func (p *PrintingKubeClient) Build(in io.Reader, _ bool) (kube.ResourceList, error) { - if p.Options == nil || !p.Options.BuildReturnResourceList { - return []*resource.Info{}, nil - } + if p.Options != nil { + if p.Options.BuildReturnError { + return nil, ErrPrintingKubeClientBuildFailure + } - manifest, err := (&kio.ByteReader{Reader: in}).Read() - if err != nil { - return nil, err - } + if p.Options.BuildReturnResourceList { + manifest, err := (&kio.ByteReader{Reader: in}).Read() + if err != nil { + return nil, err + } - resources, err := parseResources(manifest) - if err != nil { - return nil, err + resources, err := parseResources(manifest) + if err != nil { + return nil, err + } + + return resources, nil + } } - return resources, nil + return []*resource.Info{}, nil } // BuildTable implements KubeClient BuildTable.