diff --git a/cmd/helm/get_deployed_test.go b/cmd/helm/get_deployed_test.go new file mode 100644 index 00000000000..6ae8a374b9f --- /dev/null +++ b/cmd/helm/get_deployed_test.go @@ -0,0 +1,209 @@ +/* +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 main + +import ( + "bytes" + "fmt" + "testing" + "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" + "k8s.io/apimachinery/pkg/api/meta/testrestmapper" + "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 +` + +func TestGetDeployed(t *testing.T) { + const ( + namespace = `thousand-sunny` + releaseName = `one-piece` + ) + var ( + manifest bytes.Buffer + relativeCreationTimestamp = time.Now().Add(-2 * time.Minute) + relativeCreationTimestampStr = relativeCreationTimestamp.Format(time.RFC3339) + exactCreationTimestamp = time.Date(2024, time.October, 28, 0, 4, 30, 0, time.FixedZone("IST", 19800)) + exactCreationTimestampStr = exactCreationTimestamp.Format(time.RFC3339) + scheme = runtime.NewScheme() + ) + + manifestTemplateParser, err := template.New("manifestTemplate").Parse(manifestTemplate) + assert.NoError(t, err) + + assert.NoError(t, corev1.AddToScheme(scheme)) + assert.NoError(t, corev1.AddToScheme(scheme)) + assert.NoError(t, appsv1.AddToScheme(scheme)) + + restMapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(restMapper) + + prepareReleaseFunc := func(name, namespace, timestamp string, manifest bytes.Buffer, info *release.Info) []*release.Release { + err = manifestTemplateParser.Execute(&manifest, struct { + Namespace string + CreationTimestamp string + }{ + Namespace: namespace, + CreationTimestamp: timestamp, + }) + assert.NoError(t, err) + + return []*release.Release{{ + Name: name, + Namespace: namespace, + Info: info, + Manifest: manifest.String(), + }} + } + + tests := []cmdTestCase{ + { + name: "get deployed with release", + cmd: fmt.Sprintf("get deployed %s --namespace %s", releaseName, namespace), + golden: "output/get-deployed.txt", + rels: prepareReleaseFunc( + releaseName, + namespace, + relativeCreationTimestampStr, + manifest, + &release.Info{ + LastDeployed: helmtime.Unix(relativeCreationTimestamp.Unix(), 0).UTC(), + Status: release.StatusDeployed, + }, + ), + restClientGetter: configFlags, + kubeClientOpts: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + }, + { + name: "get deployed with release in json format", + cmd: fmt.Sprintf("get deployed %s --namespace %s --output json", releaseName, namespace), + golden: "output/get-deployed.json", + rels: prepareReleaseFunc( + releaseName, + namespace, + exactCreationTimestampStr, + manifest, + &release.Info{ + LastDeployed: helmtime.Unix(exactCreationTimestamp.Unix(), 0).UTC(), + Status: release.StatusDeployed, + }, + ), + restClientGetter: configFlags, + kubeClientOpts: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + }, + { + name: "get deployed with release in yaml format", + cmd: fmt.Sprintf("get deployed %s --namespace %s --output yaml", releaseName, namespace), + golden: "output/get-deployed.yaml", + rels: prepareReleaseFunc( + releaseName, + namespace, + exactCreationTimestampStr, + manifest, + &release.Info{ + LastDeployed: helmtime.Unix(exactCreationTimestamp.Unix(), 0).UTC(), + Status: release.StatusDeployed, + }, + ), + restClientGetter: configFlags, + kubeClientOpts: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + }, + } + + runTestCmd(t, tests) +} diff --git a/cmd/helm/testdata/output/get-deployed.json b/cmd/helm/testdata/output/get-deployed.json new file mode 100644 index 00000000000..e65fde0ecfd --- /dev/null +++ b/cmd/helm/testdata/output/get-deployed.json @@ -0,0 +1 @@ +[{"name":"thousand-sunny","namespace":"","apiVersion":"v1","resource":"namespaces","creationTimestamp":"2024-10-27T18:34:30Z"},{"name":"nami","namespace":"thousand-sunny","apiVersion":"v1","resource":"configmaps","creationTimestamp":"2024-10-27T18:34:30Z"},{"name":"zoro","namespace":"thousand-sunny","apiVersion":"v1","resource":"services","creationTimestamp":"2024-10-27T18:34:30Z"},{"name":"luffy","namespace":"thousand-sunny","apiVersion":"apps/v1","resource":"deployments","creationTimestamp":"2024-10-27T18:34:30Z"}] diff --git a/cmd/helm/testdata/output/get-deployed.txt b/cmd/helm/testdata/output/get-deployed.txt new file mode 100644 index 00000000000..dd727aff2fd --- /dev/null +++ b/cmd/helm/testdata/output/get-deployed.txt @@ -0,0 +1,5 @@ +NAMESPACE NAME API_VERSION AGE + namespaces/thousand-sunny v1 2m +thousand-sunny configmaps/nami v1 2m +thousand-sunny services/zoro v1 2m +thousand-sunny deployments/luffy apps/v1 2m diff --git a/cmd/helm/testdata/output/get-deployed.yaml b/cmd/helm/testdata/output/get-deployed.yaml new file mode 100644 index 00000000000..6b82aab7764 --- /dev/null +++ b/cmd/helm/testdata/output/get-deployed.yaml @@ -0,0 +1,20 @@ +- apiVersion: v1 + creationTimestamp: "2024-10-27T18:34:30Z" + name: thousand-sunny + namespace: "" + resource: namespaces +- apiVersion: v1 + creationTimestamp: "2024-10-27T18:34:30Z" + name: nami + namespace: thousand-sunny + resource: configmaps +- apiVersion: v1 + creationTimestamp: "2024-10-27T18:34:30Z" + name: zoro + namespace: thousand-sunny + resource: services +- apiVersion: apps/v1 + creationTimestamp: "2024-10-27T18:34:30Z" + name: luffy + namespace: thousand-sunny + resource: deployments diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go index d6a937c718c..338f13491c0 100644 --- a/pkg/kube/fake/printer.go +++ b/pkg/kube/fake/printer.go @@ -17,14 +17,21 @@ limitations under the License. package fake import ( + "fmt" "io" "strings" + "sync" "time" - v1 "k8s.io/api/core/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer/json" "k8s.io/cli-runtime/pkg/resource" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/yaml" "helm.sh/helm/v3/pkg/kube" ) @@ -61,7 +68,19 @@ func (p *PrintingKubeClient) Get(resources kube.ResourceList, _ bool) (map[strin if err != nil { return nil, err } - return make(map[string][]runtime.Object), nil + + if p.Options == nil || !p.Options.GetReturnResourceMap { + return make(map[string][]runtime.Object), nil + } + + result := make(map[string][]runtime.Object) + for _, r := range resources { + result[r.Name] = []runtime.Object{ + r.Object, + } + } + + return result, nil } func (p *PrintingKubeClient) Wait(resources kube.ResourceList, _ time.Duration) error { @@ -109,8 +128,22 @@ func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, _ bool) (*kub } // Build implements KubeClient Build. -func (p *PrintingKubeClient) Build(_ io.Reader, _ bool) (kube.ResourceList, error) { - return []*resource.Info{}, nil +func (p *PrintingKubeClient) Build(in io.Reader, _ bool) (kube.ResourceList, error) { + if p.Options == nil || !p.Options.BuildReturnResourceList { + return []*resource.Info{}, nil + } + + manifest, err := (&kio.ByteReader{Reader: in}).Read() + if err != nil { + return nil, err + } + + resources, err := parseResources(manifest) + if err != nil { + return nil, err + } + + return resources, nil } // BuildTable implements KubeClient BuildTable. @@ -119,8 +152,8 @@ func (p *PrintingKubeClient) BuildTable(_ io.Reader, _ bool) (kube.ResourceList, } // WaitAndGetCompletedPodPhase implements KubeClient WaitAndGetCompletedPodPhase. -func (p *PrintingKubeClient) WaitAndGetCompletedPodPhase(_ string, _ time.Duration) (v1.PodPhase, error) { - return v1.PodSucceeded, nil +func (p *PrintingKubeClient) WaitAndGetCompletedPodPhase(_ string, _ time.Duration) (corev1.PodPhase, error) { + return corev1.PodSucceeded, nil } // DeleteWithPropagationPolicy implements KubeClient delete. @@ -141,3 +174,111 @@ func bufferize(resources kube.ResourceList) io.Reader { } return strings.NewReader(builder.String()) } + +// parseResources parses Kubernetes manifest YAML as resources suitable for Helm +func parseResources(manifest []*yaml.RNode) ([]*resource.Info, error) { + // Create a scheme + scheme := runtime.NewScheme() + + // Define serializer options + serializer := json.NewSerializerWithOptions( + json.DefaultMetaFactory, + scheme, + scheme, json.SerializerOptions{ + Yaml: true, + }, + ) + + var objects []*resource.Info + for _, node := range manifest { + // Get the GVK of the rNode + gvk, err := getGVKForNode(node) + if err != nil { + return nil, fmt.Errorf("failed to get the GVK of rNode: %v", err) + } + + // Add the GVK to scheme + err = addSchemeForGVK(scheme, gvk) + if err != nil { + return nil, fmt.Errorf("failed to add GVK %q to scheme: %v", gvk, err) + } + + // Convert the rNode to JSON bytes + jsonData, err := node.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("error marshaling RNode to JSON: %w", err) + } + + // Decode the JSON data into a Kubernetes runtime.Object + obj, _, err := serializer.Decode(jsonData, nil, nil) + if err != nil { + return nil, fmt.Errorf("error decoding JSON to runtime.Object: %w", err) + } + + objects = append(objects, &resource.Info{Object: obj}) + } + + return objects, nil +} + +// getGVKForNode returns GVK from an resource YAML node +func getGVKForNode(node *yaml.RNode) (schema.GroupVersionKind, error) { + // Retrieve the apiVersion field from the RNode + apiVersionNode, err := node.Pipe(yaml.Lookup(`apiVersion`)) + if err != nil || apiVersionNode == nil { + return schema.GroupVersionKind{}, fmt.Errorf("apiVersion not found in RNode: %v", err) + } + + // Retrieve the kind field from the RNode + kindNode, err := node.Pipe(yaml.Lookup(`kind`)) + if err != nil || kindNode == nil { + return schema.GroupVersionKind{}, fmt.Errorf("kind not found in RNode: %v", err) + } + + // Extract values + apiVersion := apiVersionNode.YNode().Value + kind := kindNode.YNode().Value + + // Parse the apiVersion to get GroupVersion + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return schema.GroupVersionKind{}, fmt.Errorf("error parsing apiVersion: %v", err) + } + + return gv.WithKind(kind), nil +} + +// Mutex to protect concurrent access to the scheme +var schemeMutex sync.Mutex + +// Registry to hold AddToScheme functions for each API group. +// Add more GroupVersion to AddToScheme func mappings if required by tests. +var addToSchemeRegistry = map[schema.GroupVersion]func(*runtime.Scheme) error{ + corev1.SchemeGroupVersion: corev1.AddToScheme, + appsv1.SchemeGroupVersion: appsv1.AddToScheme, +} + +// addSchemeForGVK dynamically adds GroupVersion to scheme +func addSchemeForGVK(scheme *runtime.Scheme, gvk schema.GroupVersionKind) error { + schemeMutex.Lock() + defer schemeMutex.Unlock() + + // Exit early if GroupVersion is already registered + gv := gvk.GroupVersion() + if scheme.IsVersionRegistered(gv) { + return nil + } + + // Look up the function corresponding to current GroupVersion + addToSchemeFunc, exists := addToSchemeRegistry[gv] + if !exists { + return fmt.Errorf("no AddToScheme function registered for %s", gv) + } + + // Register the GroupVersion in the scheme + if err := addToSchemeFunc(scheme); err != nil { + return fmt.Errorf("failed to add scheme for %s: %w", gv, err) + } + + return nil +}