diff --git a/cmd/helm/get.go b/cmd/helm/get.go index 727cdaf88e5..cf72f421094 100644 --- a/cmd/helm/get.go +++ b/cmd/helm/get.go @@ -45,6 +45,7 @@ func newGetCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } cmd.AddCommand(newGetAllCmd(cfg, out)) + cmd.AddCommand(newGetDeployedCmd(cfg, out)) cmd.AddCommand(newGetValuesCmd(cfg, out)) cmd.AddCommand(newGetManifestCmd(cfg, out)) cmd.AddCommand(newGetHooksCmd(cfg, out)) diff --git a/cmd/helm/get_deployed.go b/cmd/helm/get_deployed.go new file mode 100644 index 00000000000..9d46127d101 --- /dev/null +++ b/cmd/helm/get_deployed.go @@ -0,0 +1,72 @@ +/* +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 ( + "io" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli/output" +) + +var getDeployedHelp = ` +This command prints list of resources deployed under a release. +` + +// newGetDeployedCmd creates a command for listing the resources deployed under a named release +func newGetDeployedCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + // Output format for the command output. This will be set by input flag -o (or --output). + var outfmt output.Format + + // Create get-deployed action's client + client := action.NewGetDeployed(cfg) + + cmd := &cobra.Command{ + Use: "deployed RELEASE_NAME", + Short: "list resources deployed under a named release", + Long: getDeployedHelp, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + // Run the client to list resources under the release + resourceList, err := client.Run(args[0]) + if err != nil { + return err + } + + // Create an output writer with resources listed + writer := action.NewResourceListWriter(resourceList, false) + + // Write the resources list with output format provided with input flag + return outfmt.Write(out, writer) + }, + } + + // Add flag for specifying the output format + bindOutputFlag(cmd, &outfmt) + + return cmd +} diff --git a/go.mod b/go.mod index e200d4fcb11..61c746c3554 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,8 @@ require ( k8s.io/klog/v2 v2.110.1 k8s.io/kubectl v0.29.0 oras.land/oras-go v1.2.4 + sigs.k8s.io/controller-runtime v0.16.5 + sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 sigs.k8s.io/yaml v1.3.0 ) @@ -164,6 +166,5 @@ require ( k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 // indirect - sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect ) diff --git a/go.sum b/go.sum index 2799262df39..e0e74b7b200 100644 --- a/go.sum +++ b/go.sum @@ -100,6 +100,8 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= +github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM= github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= @@ -125,6 +127,8 @@ github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo= +github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNapBOA= github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= @@ -416,6 +420,10 @@ go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1 go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= go.starlark.net v0.0.0-20230525235612-a134d8f9ddca h1:VdD38733bfYv5tUZwEIskMM93VanwNIi5bIKnDrJdEY= go.starlark.net v0.0.0-20230525235612-a134d8f9ddca/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= +go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= @@ -586,6 +594,8 @@ k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSn k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go v1.2.4 h1:djpBY2/2Cs1PV87GSJlxv4voajVOMZxqqtq9AB8YNvY= oras.land/oras-go v1.2.4/go.mod h1:DYcGfb3YF1nKjcezfX2SNlDAeQFKSXmf+qrFmrh4324= +sigs.k8s.io/controller-runtime v0.16.5 h1:yr1cEJbX08xsTW6XEIzT13KHHmIyX8Umvme2cULvFZw= +sigs.k8s.io/controller-runtime v0.16.5/go.mod h1:j7bialYoSn142nv9sCOJmQgDXQXxnroFU4VnX/brVJ0= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 h1:XX3Ajgzov2RKUdc5jW3t5jwY7Bo7dcRm+tFxT+NfgY0= diff --git a/pkg/action/get_deployed.go b/pkg/action/get_deployed.go new file mode 100644 index 00000000000..5f27c699b50 --- /dev/null +++ b/pkg/action/get_deployed.go @@ -0,0 +1,326 @@ +/* +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 ( + "context" + "errors" + "fmt" + "io" + "strings" + + "helm.sh/helm/v3/pkg/cli/output" + + "github.com/gosuri/uitable" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +// getDeployed is the action for checking the named release's deployed resources. +// +// It provides the implementation of 'helm get deployed'. +type getDeployed struct { + cfg *Configuration +} + +// NewGetDeployed creates a new GetDeployed object with the input configuration. +func NewGetDeployed(cfg *Configuration) *getDeployed { + return &getDeployed{ + cfg: cfg, + } +} + +// Run executes 'helm get deployed' against the named release. +func (g *getDeployed) Run(name string) ([]resourceElement, error) { + ctx := context.Background() + + // Check if cluster is reachable from the client + if err := g.cfg.KubeClient.IsReachable(); err != nil { + return nil, fmt.Errorf("kubeClient is not reachable: %w", err) + } + + // Get the release details. + // + // The revision is set to 0 to get the latest revision of the release. + release, err := g.cfg.releaseContent(name, 0) + if err != nil { + return nil, fmt.Errorf("failed to fetch release content: %w", err) + } + + // Create function to iterate over all the resources in the release manifest + resourceList := make([]resourceElement, 0) + listResourcesFn := kio.FilterFunc(func(resources []*yaml.RNode) ([]*yaml.RNode, error) { + // Iterate over the resource in manifest YAML + for _, manifest := range resources { + // Process resource to be printable by the "helm get deployed" command's output writer + resource, err := processResourceForGetDeployed(ctx, manifest, g.cfg) + if err != nil { + return nil, err + } + + // Add current resource to list + resourceList = append(resourceList, *resource) + } + + // Return resources as is, as this function is only expected to read the resource manifest + return resources, nil + }) + + // Run the manifest YAML through the function to process the resources list + err = kio.Pipeline{ + Inputs: []kio.Reader{&kio.ByteReader{Reader: strings.NewReader(release.Manifest)}}, + Filters: []kio.Filter{listResourcesFn}, + }.Execute() + if err != nil { + return nil, fmt.Errorf("failed to run read and process release manifests: %w", err) + } + + return resourceList, nil +} + +// processResourceForGetDeployed processes resource to be printable by the "helm get deployed" command's output writer +func processResourceForGetDeployed(ctx context.Context, manifest *yaml.RNode, + cfg *Configuration) (*resourceElement, error) { + // Extract the current resource's name + name, err := extractResourceName(manifest) + if err != nil { + return nil, err + } + + // Extract the current resource's GVK + gvk, err := extractGVK(manifest) + if err != nil { + return nil, err + } + + // Create a REST mapper from config + restMapper, err := cfg.RESTClientGetter.ToRESTMapper() + if err != nil { + return nil, fmt.Errorf("failed to get the REST mapper: %w", err) + } + + // Extract the current resource's namespace + defaultNamespace := cfg.KubeClient.GetNamespace() + namespace, err := extractResourceNamespace(manifest, *gvk, restMapper, defaultNamespace) + if err != nil { + return nil, err + } + + // Create a REST mapping for the current resource and GVK + restMappingForGVK, err := restMapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return nil, fmt.Errorf("failed to get the REST mapping for GVK: %w", err) + } + + // Load the REST config for client + config, err := cfg.RESTClientGetter.ToRESTConfig() + if err != nil { + return nil, fmt.Errorf("failed to get the REST config: %w", err) + } + + // Create a dynamic client + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create a dynamic client: %w", err) + } + + // Get the current resource's details from the cluster + resource, err := getResource(ctx, dynamicClient, name, namespace, restMappingForGVK.Resource) + if err != nil { + return nil, err + } + + return &resourceElement{ + Resource: restMappingForGVK.Resource.Resource, + Name: name, + Namespace: namespace, + APIVersion: resource.GetAPIVersion(), + CreatedTime: resource.GetCreationTimestamp().String(), + }, nil +} + +// getResource gets the Kubernetes resource using dynamic client. +func getResource(ctx context.Context, client *dynamic.DynamicClient, name, namespace string, + gvr schema.GroupVersionResource) (*unstructured.Unstructured, error) { + // If the namespace is not empty, it looks for a namespace-scoped resource. It is the responsibility of the caller + // to provide the namespace value for the namespace-scoped resource even if it uses the default namespace. + if namespace != "" { + resource, err := client.Resource(gvr). + Namespace(namespace). + Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get the namespaced resource %q: %w", gvr, err) + } + + return resource, nil + } + + // Get cluster-scoped resource + resource, err := client.Resource(gvr). + Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get the non-namespaced resource %q: %w", gvr, err) + } + + return resource, nil +} + +// extractGVK extracts group-version and kind from the manifest YAML, and forms a GVK out of it. +func extractGVK(manifest *yaml.RNode) (*schema.GroupVersionKind, error) { + // Get the group-version field from manifest YAML + gvField, err := manifest.GetFieldValue("apiVersion") + if err != nil { + return nil, fmt.Errorf("failed to get the resource apiVersion field: %w", err) + } + + // Parse group-version as string + gv, ok := gvField.(string) + if !ok { + return nil, fmt.Errorf("group-version not parsable as string: %v", gvField) + } + + // Get the kind field from manifest YAML + kindField, err := manifest.GetFieldValue("kind") + if err != nil { + return nil, fmt.Errorf("failed to get the resource kind field: %w", err) + } + + // Parse kind as string + kind, ok := kindField.(string) + if !ok { + return nil, fmt.Errorf("kind not parsable as string: %v", kindField) + } + + // Create GVK out of group-version and kind from manifest + gvk := schema.FromAPIVersionAndKind(gv, kind) + + return &gvk, nil +} + +// extractResourceName extracts resource name field ("manifest.name") from the manifest YAML. +// +// Note: The YAML RNode should be of a single resource. +func extractResourceNamespace(manifest *yaml.RNode, gvk schema.GroupVersionKind, restMapper meta.RESTMapper, + defaultNamespace string) (string, error) { + // Get the namespace field from manifest YAML + field, err := manifest.GetFieldValue("metadata.namespace") + if err != nil && !errors.Is(err, yaml.NoFieldError{Field: "metadata.namespace"}) { + return "", fmt.Errorf("failed to get the resource namespace field: %w", err) + } + + // When field is found in the manifest, parse it as string and return + if field != nil { + namespace, ok := field.(string) + if !ok { + return "", fmt.Errorf("resource namespace not parsable as string: %v", field) + } + + return namespace, nil + } + + // Check whether the current resource is namespace-scoped + isResourceNamespaced, err := apiutil.IsGVKNamespaced(gvk, restMapper) + if err != nil { + return "", fmt.Errorf("failed to check if GVK is namespaced: %w", err) + } + + // When the resource is namespace-scoped, and namespace field is missing (or empty) in the manifest, use the + // default namespace. Note: The default namespace can be "default" or an overridden value in kube config. + // + // TODO :: Bhargav-InfraCloud :: If Helm manifest command (helm get manifest RELEASE_NAME) appends the namespace, + // for templates where the namespace is not specified, may it be “default” or namespace value that is overridden in + // kube config, assigning default namespace here can be removed. And maybe an error can be returned if it has an + // empty namespace value even after that. + if isResourceNamespaced { + return defaultNamespace, nil + } + + // When the resource is cluster-scoped, return empty string + return "", nil +} + +// extractResourceName extracts resource name field ("manifest.name") from the manifest YAML. +// +// Note: The YAML RNode should be of a single resource. +func extractResourceName(manifest *yaml.RNode) (string, error) { + // Get the name field from manifest YAML + field, err := manifest.GetFieldValue("metadata.name") + if err != nil { + return "", fmt.Errorf("failed to get the resource name field: %w", err) + } + + // Parse resource name as string + name, ok := field.(string) + if !ok { + return "", fmt.Errorf("resource name not parsable as string: %v", field) + } + + return name, nil +} + +type resourceElement struct { + Name string `json:"name"` // Resource's name + Namespace string `json:"namespace"` // Resource's namespace + APIVersion string `json:"api_version"` // Resource's group-version + Resource string `json:"resource"` // Resource type (eg. pods, deployments, etc.) + CreatedTime string `json:"created"` // Resource creation timestamp +} + +type resourceListWriter struct { + releases []resourceElement // Resources list + noHeaders bool // Toggle to disable headers in tabular format +} + +// NewResourceListWriter creates a output writer for Kubernetes resources to be listed with 'helm get deployed' +func NewResourceListWriter(resources []resourceElement, noHeaders bool) output.Writer { + return &resourceListWriter{resources, noHeaders} +} + +// WriteTable prints the resources list in a tabular format +func (r *resourceListWriter) WriteTable(out io.Writer) error { + // Create table writer + table := uitable.New() + + // Add headers if enabled + if !r.noHeaders { + table.AddRow("NAMESPACE", "NAME", "API_VERSION", "CREATED_TIME") + } + + // Add resources to table + for _, r := range r.releases { + table.AddRow(r.Namespace, fmt.Sprintf("%s/%s", r.Resource, r.Name), r.APIVersion, r.CreatedTime) + } + + // Format the table and write to output writer + return output.EncodeTable(out, table) +} + +// WriteTable prints the resources list in a JSON format +func (r *resourceListWriter) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, r.releases) +} + +// WriteTable prints the resources list in a YAML format +func (r *resourceListWriter) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, r.releases) +} diff --git a/pkg/kube/client.go b/pkg/kube/client.go index 9df833a434c..81ec1cbda35 100644 --- a/pkg/kube/client.go +++ b/pkg/kube/client.go @@ -320,7 +320,8 @@ func (c *Client) WaitForDelete(resources ResourceList, timeout time.Duration) er return w.waitForDeletedResources(resources) } -func (c *Client) namespace() string { +// GetNamespace returns the namespace set in the client (in kube config), or if that is missing, it returns "default" +func (c *Client) GetNamespace() string { if c.Namespace != "" { return c.Namespace } @@ -334,7 +335,7 @@ func (c *Client) namespace() string { func (c *Client) newBuilder() *resource.Builder { return c.Factory.NewBuilder(). ContinueOnError(). - NamespaceParam(c.namespace()). + NamespaceParam(c.GetNamespace()). DefaultNamespace(). Flatten() } @@ -825,7 +826,7 @@ func (c *Client) WaitAndGetCompletedPodPhase(name string, timeout time.Duration) return v1.PodUnknown, err } to := int64(timeout) - watcher, err := client.CoreV1().Pods(c.namespace()).Watch(context.Background(), metav1.ListOptions{ + watcher, err := client.CoreV1().Pods(c.GetNamespace()).Watch(context.Background(), metav1.ListOptions{ FieldSelector: fmt.Sprintf("metadata.name=%s", name), TimeoutSeconds: &to, }) diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go index cc2c84b40b8..ad6ae447154 100644 --- a/pkg/kube/fake/printer.go +++ b/pkg/kube/fake/printer.go @@ -127,6 +127,13 @@ func (p *PrintingKubeClient) DeleteWithPropagationPolicy(resources kube.Resource return &kube.Result{Deleted: resources}, nil } +// GetNamespace returns the namespace set in the client. +// +// This is not required by the PrintingKubeClient, but to implement (pkg/kube).Interface +func (p *PrintingKubeClient) GetNamespace() string { + return "" +} + func bufferize(resources kube.ResourceList) io.Reader { var builder strings.Builder for _, info := range resources { diff --git a/pkg/kube/interface.go b/pkg/kube/interface.go index ce42ed9501d..5dfff95956d 100644 --- a/pkg/kube/interface.go +++ b/pkg/kube/interface.go @@ -70,6 +70,10 @@ type Interface interface { // IsReachable checks whether the client is able to connect to the cluster. IsReachable() error + + // GetNamespace returns the namespace set in the client (in kube config). + // Or if that is missing, it returns "default" + GetNamespace() string } // InterfaceExt is introduced to avoid breaking backwards compatibility for Interface implementers.