Repository: helm/helm (go)

Issue:
Introduce `helm get metadata` command
### Problem
There's a command `helm get` with a number of subcommands:
* `values`
* `notes`
* `manifest`
* `hooks`
* `all`

None of them return the chart version that was used. The chart version is returned in `helm list` though.
None of them return the namespace or revision or helm chart app version as well.
Deploy timestamp is returned, but only by `all` command, which does not have `--output` flag, so it can't output to machine-readable JSON or YAML formats.

### Proposal
Introduce `helm get metadata RELEASE_NAME [flags]` command:
* to return the following release attributes:
  * `name` -- the release name
  * `chart` -- the chart name (note: just a chart name; chart version goes in a separate attribute)
  * `version` -- the helm chart `version` field
  * `appVersion` -- the helm chart `appVersion` field
  * `namespace` -- the namespace where the chart is installed into
  * `revision` -- the revision of the release
  * `status` -- the release status
* to have `--output` flag with options:
  * `table`  -- default
  * `json`
  * `yaml`

Pull request description:
Add `helm get metadata` command
<!--  Thanks for sending a pull request!  Here are some tips for you:
1. Make sure to read the Contributing Guide before submitting your PR: https://github.com/helm/helm/blob/main/CONTRIBUTING.md
2. If this PR closes another issue, add 'closes #<issue number>' somewhere in the PR summary. GitHub will automatically close that issue when this PR gets merged. Alternatively, adding 'refs #<issue number>' will not close the issue, but help provide the reviewer more context.-->

Closes #11625

**What this PR does / why we need it**:
The PR introduces a new command `helm get metadata`

**Special notes for your reviewer**:

**If applicable**:
- [x] this PR contains unit tests
- [x] this PR has been tested for backwards compatibility


Patch:
diff --git a/cmd/helm/get.go b/cmd/helm/get.go
index 7c4854b59..727cdaf88 100644
--- a/cmd/helm/get.go
+++ b/cmd/helm/get.go
@@ -33,6 +33,7 @@ get extended information about the release, including:
 - The generated manifest file
 - The notes provided by the chart of the release
 - The hooks associated with the release
+- The metadata of the release
 `
 
 func newGetCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
@@ -48,6 +49,7 @@ func newGetCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
 	cmd.AddCommand(newGetManifestCmd(cfg, out))
 	cmd.AddCommand(newGetHooksCmd(cfg, out))
 	cmd.AddCommand(newGetNotesCmd(cfg, out))
+	cmd.AddCommand(newGetMetadataCmd(cfg, out))
 
 	return cmd
 }
diff --git a/cmd/helm/get_metadata.go b/cmd/helm/get_metadata.go
new file mode 100644
index 000000000..0c8302365
--- /dev/null
+++ b/cmd/helm/get_metadata.go
@@ -0,0 +1,94 @@
+/*
+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 (
+	"fmt"
+	"io"
+	"log"
+
+	"github.com/spf13/cobra"
+
+	"helm.sh/helm/v3/cmd/helm/require"
+	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/cli/output"
+)
+
+type metadataWriter struct {
+	metadata *action.Metadata
+}
+
+func newGetMetadataCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
+	var outfmt output.Format
+	client := action.NewGetMetadata(cfg)
+
+	cmd := &cobra.Command{
+		Use:   "metadata RELEASE_NAME",
+		Short: "This command fetches metadata for a given release",
+		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 {
+			releaseMetadata, err := client.Run(args[0])
+			if err != nil {
+				return err
+			}
+			return outfmt.Write(out, &metadataWriter{releaseMetadata})
+		},
+	}
+
+	f := cmd.Flags()
+	f.IntVar(&client.Version, "revision", 0, "specify release revision")
+	err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+		if len(args) == 1 {
+			return compListRevisions(toComplete, cfg, args[0])
+		}
+		return nil, cobra.ShellCompDirectiveNoFileComp
+	})
+
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	bindOutputFlag(cmd, &outfmt)
+
+	return cmd
+}
+
+func (w metadataWriter) WriteTable(out io.Writer) error {
+	_, _ = fmt.Fprint(out, fmt.Sprintf("NAME: %v\n", w.metadata.Name))
+	_, _ = fmt.Fprint(out, fmt.Sprintf("CHART: %v\n", w.metadata.Chart))
+	_, _ = fmt.Fprint(out, fmt.Sprintf("VERSION: %v\n", w.metadata.Version))
+	_, _ = fmt.Fprint(out, fmt.Sprintf("APP_VERSION: %v\n", w.metadata.AppVersion))
+	_, _ = fmt.Fprint(out, fmt.Sprintf("NAMESPACE: %v\n", w.metadata.Namespace))
+	_, _ = fmt.Fprint(out, fmt.Sprintf("REVISION: %v\n", w.metadata.Revision))
+	_, _ = fmt.Fprint(out, fmt.Sprintf("STATUS: %v\n", w.metadata.Status))
+	_, _ = fmt.Fprint(out, fmt.Sprintf("DEPLOYED_AT: %v\n", w.metadata.DeployedAt))
+	return nil
+}
+
+func (w metadataWriter) WriteJSON(out io.Writer) error {
+	return output.EncodeJSON(out, w.metadata)
+}
+
+func (w metadataWriter) WriteYAML(out io.Writer) error {
+	return output.EncodeYAML(out, w.metadata)
+}
diff --git a/cmd/helm/status.go b/cmd/helm/status.go
index aa22aa02a..bfe058494 100644
--- a/cmd/helm/status.go
+++ b/cmd/helm/status.go
@@ -126,15 +126,18 @@ func (s statusPrinter) WriteTable(out io.Writer) error {
 	if s.release == nil {
 		return nil
 	}
-	fmt.Fprintf(out, "NAME: %s\n", s.release.Name)
+	_, _ = fmt.Fprintf(out, "NAME: %s\n", s.release.Name)
 	if !s.release.Info.LastDeployed.IsZero() {
-		fmt.Fprintf(out, "LAST DEPLOYED: %s\n", s.release.Info.LastDeployed.Format(time.ANSIC))
+		_, _ = fmt.Fprintf(out, "LAST DEPLOYED: %s\n", s.release.Info.LastDeployed.Format(time.ANSIC))
 	}
-	fmt.Fprintf(out, "NAMESPACE: %s\n", s.release.Namespace)
-	fmt.Fprintf(out, "STATUS: %s\n", s.release.Info.Status.String())
-	fmt.Fprintf(out, "REVISION: %d\n", s.release.Version)
+	_, _ = fmt.Fprintf(out, "CHART: %s\n", s.release.Chart.Metadata.Name)
+	_, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", s.release.Namespace)
+	_, _ = fmt.Fprintf(out, "STATUS: %s\n", s.release.Info.Status.String())
+	_, _ = fmt.Fprintf(out, "REVISION: %d\n", s.release.Version)
+	_, _ = fmt.Fprintf(out, "VERSION: %s\n", s.release.Chart.Metadata.Version)
+	_, _ = fmt.Fprintf(out, "APP_VERSION: %s\n", s.release.Chart.Metadata.AppVersion)
 	if s.showDescription {
-		fmt.Fprintf(out, "DESCRIPTION: %s\n", s.release.Info.Description)
+		_, _ = fmt.Fprintf(out, "DESCRIPTION: %s\n", s.release.Info.Description)
 	}
 
 	if s.showResources && s.release.Info.Resources != nil && len(s.release.Info.Resources) > 0 {
@@ -149,31 +152,31 @@ func (s statusPrinter) WriteTable(out io.Writer) error {
 		}
 
 		for _, t := range keys {
-			fmt.Fprintf(buf, "==> %s\n", t)
+			_, _ = fmt.Fprintf(buf, "==> %s\n", t)
 
 			vk := s.release.Info.Resources[t]
 			for _, resource := range vk {
 				if err := printer.PrintObj(resource, buf); err != nil {
-					fmt.Fprintf(buf, "failed to print object type %s: %v\n", t, err)
+					_, _ = fmt.Fprintf(buf, "failed to print object type %s: %v\n", t, err)
 				}
 			}
 
 			buf.WriteString("\n")
 		}
 
-		fmt.Fprintf(out, "RESOURCES:\n%s\n", buf.String())
+		_, _ = fmt.Fprintf(out, "RESOURCES:\n%s\n", buf.String())
 	}
 
 	executions := executionsByHookEvent(s.release)
 	if tests, ok := executions[release.HookTest]; !ok || len(tests) == 0 {
-		fmt.Fprintln(out, "TEST SUITE: None")
+		_, _ = fmt.Fprintln(out, "TEST SUITE: None")
 	} else {
 		for _, h := range tests {
 			// Don't print anything if hook has not been initiated
 			if h.LastRun.StartedAt.IsZero() {
 				continue
 			}
-			fmt.Fprintf(out, "TEST SUITE:     %s\n%s\n%s\n%s\n",
+			_, _ = fmt.Fprintf(out, "TEST SUITE:     %s\n%s\n%s\n%s\n",
 				h.Name,
 				fmt.Sprintf("Last Started:   %s", h.LastRun.StartedAt.Format(time.ANSIC)),
 				fmt.Sprintf("Last Completed: %s", h.LastRun.CompletedAt.Format(time.ANSIC)),
@@ -183,38 +186,38 @@ func (s statusPrinter) WriteTable(out io.Writer) error {
 	}
 
 	if s.debug {
-		fmt.Fprintln(out, "USER-SUPPLIED VALUES:")
+		_, _ = fmt.Fprintln(out, "USER-SUPPLIED VALUES:")
 		err := output.EncodeYAML(out, s.release.Config)
 		if err != nil {
 			return err
 		}
 		// Print an extra newline
-		fmt.Fprintln(out)
+		_, _ = fmt.Fprintln(out)
 
 		cfg, err := chartutil.CoalesceValues(s.release.Chart, s.release.Config)
 		if err != nil {
 			return err
 		}
 
-		fmt.Fprintln(out, "COMPUTED VALUES:")
+		_, _ = fmt.Fprintln(out, "COMPUTED VALUES:")
 		err = output.EncodeYAML(out, cfg.AsMap())
 		if err != nil {
 			return err
 		}
 		// Print an extra newline
-		fmt.Fprintln(out)
+		_, _ = fmt.Fprintln(out)
 	}
 
 	if strings.EqualFold(s.release.Info.Description, "Dry run complete") || s.debug {
-		fmt.Fprintln(out, "HOOKS:")
+		_, _ = fmt.Fprintln(out, "HOOKS:")
 		for _, h := range s.release.Hooks {
-			fmt.Fprintf(out, "---\n# Source: %s\n%s\n", h.Path, h.Manifest)
+			_, _ = fmt.Fprintf(out, "---\n# Source: %s\n%s\n", h.Path, h.Manifest)
 		}
-		fmt.Fprintf(out, "MANIFEST:\n%s\n", s.release.Manifest)
+		_, _ = fmt.Fprintf(out, "MANIFEST:\n%s\n", s.release.Manifest)
 	}
 
 	if len(s.release.Info.Notes) > 0 {
-		fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(s.release.Info.Notes))
+		_, _ = fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(s.release.Info.Notes))
 	}
 	return nil
 }
diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go
new file mode 100644
index 000000000..ec096ae16
--- /dev/null
+++ b/pkg/action/get_metadata.go
@@ -0,0 +1,69 @@
+/*
+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 "time"
+
+// GetMetadata is the action for checking a given release's metadata.
+//
+// It provides the implementation of 'helm get metadata'.
+type GetMetadata struct {
+	cfg *Configuration
+
+	Version int
+}
+
+type Metadata struct {
+	Name       string `json:"name" yaml:"name"`
+	Chart      string `json:"chart" yaml:"chart"`
+	Version    string `json:"version" yaml:"version"`
+	AppVersion string `json:"appVersion" yaml:"appVersion"`
+	Namespace  string `json:"namespace" yaml:"namespace"`
+	Revision   int    `json:"revision" yaml:"revision"`
+	Status     string `json:"status" yaml:"status"`
+	DeployedAt string `json:"deployedAt" yaml:"deployedAt"`
+}
+
+// NewGetMetadata creates a new GetMetadata object with the given configuration.
+func NewGetMetadata(cfg *Configuration) *GetMetadata {
+	return &GetMetadata{
+		cfg: cfg,
+	}
+}
+
+// Run executes 'helm get metadata' against the given release.
+func (g *GetMetadata) Run(name string) (*Metadata, error) {
+	if err := g.cfg.KubeClient.IsReachable(); err != nil {
+		return nil, err
+	}
+
+	rel, err := g.cfg.releaseContent(name, g.Version)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Metadata{
+		Name:       rel.Name,
+		Chart:      rel.Chart.Metadata.Name,
+		Version:    rel.Chart.Metadata.Version,
+		AppVersion: rel.Chart.Metadata.AppVersion,
+		Namespace:  rel.Namespace,
+		Revision:   rel.Version,
+		Status:     rel.Info.Status.String(),
+		DeployedAt: rel.Info.LastDeployed.Format(time.RFC3339),
+	}, nil
+}


Test patch:
diff --git a/cmd/helm/get_metadata_test.go b/cmd/helm/get_metadata_test.go
new file mode 100644
index 000000000..b6f0ab9f2
--- /dev/null
+++ b/cmd/helm/get_metadata_test.go
@@ -0,0 +1,66 @@
+/*
+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 (
+	"testing"
+
+	"helm.sh/helm/v3/pkg/release"
+)
+
+func TestGetMetadataCmd(t *testing.T) {
+	tests := []cmdTestCase{{
+		name:   "get metadata with a release",
+		cmd:    "get metadata thomas-guide",
+		golden: "output/get-metadata.txt",
+		rels:   []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})},
+	}, {
+		name:      "get metadata requires release name arg",
+		cmd:       "get metadata",
+		golden:    "output/get-metadata-args.txt",
+		rels:      []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})},
+		wantError: true,
+	}, {
+		name:   "get metadata to json",
+		cmd:    "get metadata thomas-guide --output json",
+		golden: "output/get-metadata.json",
+		rels:   []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})},
+	}, {
+		name:   "get metadata to yaml",
+		cmd:    "get metadata thomas-guide --output yaml",
+		golden: "output/get-metadata.yaml",
+		rels:   []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})},
+	}}
+	runTestCmd(t, tests)
+}
+
+func TestGetMetadataCompletion(t *testing.T) {
+	checkReleaseCompletion(t, "get metadata", false)
+}
+
+func TestGetMetadataRevisionCompletion(t *testing.T) {
+	revisionFlagCompletionTest(t, "get metadata")
+}
+
+func TestGetMetadataOutputCompletion(t *testing.T) {
+	outputFlagCompletionTest(t, "get metadata")
+}
+
+func TestGetMetadataFileCompletion(t *testing.T) {
+	checkFileCompletion(t, "get metadata", false)
+	checkFileCompletion(t, "get metadata myrelease", false)
+}
diff --git a/cmd/helm/testdata/output/get-metadata-args.txt b/cmd/helm/testdata/output/get-metadata-args.txt
new file mode 100644
index 000000000..acd3f4c15
--- /dev/null
+++ b/cmd/helm/testdata/output/get-metadata-args.txt
@@ -0,0 +1,3 @@
+Error: "helm get metadata" requires 1 argument
+
+Usage:  helm get metadata RELEASE_NAME [flags]
diff --git a/cmd/helm/testdata/output/get-metadata.json b/cmd/helm/testdata/output/get-metadata.json
new file mode 100644
index 000000000..1d5152b24
--- /dev/null
+++ b/cmd/helm/testdata/output/get-metadata.json
@@ -0,0 +1,1 @@
+{"name":"thomas-guide","chart":"foo","version":"0.1.0-beta.1","appVersion":"1.0","namespace":"default","revision":1,"status":"deployed","deployedAt":"1977-09-02T22:04:05Z"}
diff --git a/cmd/helm/testdata/output/get-metadata.txt b/cmd/helm/testdata/output/get-metadata.txt
new file mode 100644
index 000000000..b91f1b86a
--- /dev/null
+++ b/cmd/helm/testdata/output/get-metadata.txt
@@ -0,0 +1,8 @@
+NAME: thomas-guide
+CHART: foo
+VERSION: 0.1.0-beta.1
+APP_VERSION: 1.0
+NAMESPACE: default
+REVISION: 1
+STATUS: deployed
+DEPLOYED_AT: 1977-09-02T22:04:05Z
diff --git a/cmd/helm/testdata/output/get-metadata.yaml b/cmd/helm/testdata/output/get-metadata.yaml
new file mode 100644
index 000000000..b6d49b038
--- /dev/null
+++ b/cmd/helm/testdata/output/get-metadata.yaml
@@ -0,0 +1,8 @@
+appVersion: "1.0"
+chart: foo
+deployedAt: "1977-09-02T22:04:05Z"
+name: thomas-guide
+namespace: default
+revision: 1
+status: deployed
+version: 0.1.0-beta.1
diff --git a/cmd/helm/testdata/output/get-release.txt b/cmd/helm/testdata/output/get-release.txt
index f6c3b57eb..3e83ca946 100644
--- a/cmd/helm/testdata/output/get-release.txt
+++ b/cmd/helm/testdata/output/get-release.txt
@@ -1,8 +1,11 @@
 NAME: thomas-guide
 LAST DEPLOYED: Fri Sep  2 22:04:05 1977
+CHART: foo
 NAMESPACE: default
 STATUS: deployed
 REVISION: 1
+VERSION: 0.1.0-beta.1
+APP_VERSION: 1.0
 TEST SUITE: None
 USER-SUPPLIED VALUES:
 name: value