From fc2a5e3fec95f05e1afa5af9cc547e346c785a4e Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Mon, 22 Jun 2026 02:18:59 +0530 Subject: [PATCH] feat: record and show installed chart source location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record where a release's chart was installed or upgraded from and surface it via `helm list` and `helm get metadata` — addressing both surfaces called out in #4256. `helm install` and `helm upgrade` set a `meta.helm.sh/release-source` annotation on the chart metadata (the `--repo` URL when supplied, otherwise the chart reference). The annotation is written after any `--dependency-update` chart reload so it survives the reload, and any credentials embedded in a repo URL are stripped before the value is persisted so secrets are not leaked into the release record. - `helm list`: new `source` field in JSON/YAML output (omitted when empty, so existing releases keep their schema) plus an opt-in `--show-source` flag for the table column, leaving the default table layout unchanged for scripts that parse it. - `helm get metadata`: new `source` field, shown in the table only when set. Closes #4256 Signed-off-by: Shaan Satsangi --- pkg/action/get_metadata.go | 10 +++ pkg/action/get_metadata_test.go | 65 +++++++++++++++ pkg/cmd/get_metadata.go | 3 + pkg/cmd/install.go | 5 ++ pkg/cmd/list.go | 35 ++++++-- pkg/cmd/list_test.go | 28 ++++++- pkg/cmd/release_source.go | 77 ++++++++++++++++++ pkg/cmd/release_source_test.go | 86 ++++++++++++++++++++ pkg/cmd/testdata/output/list-json.txt | 2 +- pkg/cmd/testdata/output/list-show-source.txt | 9 ++ pkg/cmd/testdata/output/list-yaml.txt | 1 + pkg/cmd/upgrade.go | 5 ++ 12 files changed, 312 insertions(+), 14 deletions(-) create mode 100644 pkg/cmd/release_source.go create mode 100644 pkg/cmd/release_source_test.go create mode 100644 pkg/cmd/testdata/output/list-show-source.txt diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go index 7d5a3f5f9..3754811eb 100644 --- a/pkg/action/get_metadata.go +++ b/pkg/action/get_metadata.go @@ -28,6 +28,11 @@ import ( "helm.sh/helm/v4/pkg/release" ) +// ReleaseSourceAnnotation is the chart metadata annotation under which Helm +// records the location a release's chart was installed or upgraded from, so it +// can be surfaced by 'helm list --show-source' and 'helm get metadata'. +const ReleaseSourceAnnotation = "meta.helm.sh/release-source" + // GetMetadata is the action for checking a given release's metadata. // // It provides the implementation of 'helm get metadata'. @@ -42,6 +47,10 @@ type Metadata struct { Chart string `json:"chart" yaml:"chart"` Version string `json:"version" yaml:"version"` AppVersion string `json:"appVersion" yaml:"appVersion"` + // Source is where the chart was installed or upgraded from (a repository URL + // or chart reference), recorded via the meta.helm.sh/release-source + // annotation. Empty for releases created before this was recorded. + Source string `json:"source,omitempty" yaml:"source,omitempty"` // Annotations are fetched from the Chart.yaml file Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` // Labels of the release which are stored in driver metadata fields storage @@ -98,6 +107,7 @@ func (g *GetMetadata) Run(name string) (*Metadata, error) { Chart: chrt.Metadata.Name, Version: chrt.Metadata.Version, AppVersion: chrt.Metadata.AppVersion, + Source: chrt.Metadata.Annotations[ReleaseSourceAnnotation], Dependencies: ac.MetaDependencies(), Annotations: chrt.Metadata.Annotations, Labels: rac.Labels(), diff --git a/pkg/action/get_metadata_test.go b/pkg/action/get_metadata_test.go index b488abf64..e6b33bec4 100644 --- a/pkg/action/get_metadata_test.go +++ b/pkg/action/get_metadata_test.go @@ -328,6 +328,71 @@ func TestGetMetadata_Run_WithAnnotations(t *testing.T) { assert.Equal(t, "test-value", result.Annotations["custom.annotation"]) } +func TestGetMetadata_Run_WithSource(t *testing.T) { + cfg := actionConfigFixture(t) + client := NewGetMetadata(cfg) + + releaseName := "test-release" + deployedTime := time.Now() + + rel := &release.Release{ + Name: releaseName, + Info: &release.Info{ + Status: common.StatusDeployed, + LastDeployed: deployedTime, + }, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "v1.2.3", + Annotations: map[string]string{ + ReleaseSourceAnnotation: "https://charts.example.com", + }, + }, + }, + Version: 1, + Namespace: "default", + } + + require.NoError(t, cfg.Releases.Create(rel)) + + result, err := client.Run(releaseName) + require.NoError(t, err) + + assert.Equal(t, "https://charts.example.com", result.Source) +} + +func TestGetMetadata_Run_SourceEmptyWhenAnnotationAbsent(t *testing.T) { + cfg := actionConfigFixture(t) + client := NewGetMetadata(cfg) + + releaseName := "test-release" + rel := &release.Release{ + Name: releaseName, + Info: &release.Info{ + Status: common.StatusDeployed, + LastDeployed: time.Now(), + }, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "v1.2.3", + }, + }, + Version: 1, + Namespace: "default", + } + + require.NoError(t, cfg.Releases.Create(rel)) + + result, err := client.Run(releaseName) + require.NoError(t, err) + + assert.Empty(t, result.Source) +} + func TestGetMetadata_Run_SpecificVersion(t *testing.T) { cfg := actionConfigFixture(t) client := NewGetMetadata(cfg) diff --git a/pkg/cmd/get_metadata.go b/pkg/cmd/get_metadata.go index c26af646f..1e89c13af 100644 --- a/pkg/cmd/get_metadata.go +++ b/pkg/cmd/get_metadata.go @@ -94,6 +94,9 @@ func (w metadataWriter) WriteTable(out io.Writer) error { _, _ = fmt.Fprintf(out, "CHART: %v\n", w.metadata.Chart) _, _ = fmt.Fprintf(out, "VERSION: %v\n", w.metadata.Version) _, _ = fmt.Fprintf(out, "APP_VERSION: %v\n", w.metadata.AppVersion) + if w.metadata.Source != "" { + _, _ = fmt.Fprintf(out, "SOURCE: %v\n", w.metadata.Source) + } _, _ = fmt.Fprintf(out, "ANNOTATIONS: %v\n", k8sLabels.Set(w.metadata.Annotations).String()) _, _ = fmt.Fprintf(out, "LABELS: %v\n", k8sLabels.Set(w.metadata.Labels).String()) _, _ = fmt.Fprintf(out, "DEPENDENCIES: %v\n", w.metadata.FormattedDepNames()) diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 832968ff0..801527f91 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -344,6 +344,11 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options cancel() }() + // Record where the chart was installed from so 'helm list --show-source' can + // surface it later. This is done after any dependency-update reload above so + // the annotation survives the chart being reloaded from disk. + setReleaseSource(chartRequested, chartRef, client.RepoURL) + ri, err := client.RunWithContext(ctx, chartRequested, vals) rel, rerr := releaserToV1Release(ri) if rerr != nil { diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 54becddca..8cad89c9e 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -65,6 +65,7 @@ flag with the '--offset' flag allows you to page through results. func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client := action.NewList(cfg) var outfmt output.Format + var showSource bool cmd := &cobra.Command{ Use: "list", @@ -113,7 +114,7 @@ func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } } - return outfmt.Write(out, newReleaseListWriter(results, client.TimeFormat, client.NoHeaders, settings.ShouldDisableColor())) + return outfmt.Write(out, newReleaseListWriter(results, client.TimeFormat, client.NoHeaders, settings.ShouldDisableColor(), showSource)) }, } @@ -134,6 +135,7 @@ func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.IntVar(&client.Offset, "offset", 0, "next release index in the list, used to offset from start value") f.StringVarP(&client.Filter, "filter", "f", "", "a regular expression (Perl compatible). Any releases that match the expression will be included in the results") f.StringVarP(&client.Selector, "selector", "l", "", "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2). Works only for secret(default) and configmap storage backends.") + f.BoolVar(&showSource, "show-source", false, "add a SOURCE column to the table output showing where each release's chart was installed from (chart reference or repository URL); JSON/YAML output always includes the 'source' field") bindOutputFlag(cmd, &outfmt) return cmd @@ -147,18 +149,24 @@ type releaseElement struct { Status string `json:"status"` Chart string `json:"chart"` AppVersion string `json:"app_version"` + Source string `json:"source,omitempty"` } type releaseListWriter struct { - releases []releaseElement - noHeaders bool - noColor bool + releases []releaseElement + noHeaders bool + noColor bool + showSource bool } -func newReleaseListWriter(releases []*release.Release, timeFormat string, noHeaders bool, noColor bool) *releaseListWriter { +func newReleaseListWriter(releases []*release.Release, timeFormat string, noHeaders bool, noColor bool, showSource bool) *releaseListWriter { // Initialize the array so no results returns an empty array instead of null elements := make([]releaseElement, 0, len(releases)) for _, r := range releases { + source := "" + if r.Chart != nil && r.Chart.Metadata != nil && r.Chart.Metadata.Annotations != nil { + source = r.Chart.Metadata.Annotations[action.ReleaseSourceAnnotation] + } element := releaseElement{ Name: r.Name, Namespace: r.Namespace, @@ -166,6 +174,7 @@ func newReleaseListWriter(releases []*release.Release, timeFormat string, noHead Status: r.Info.Status.String(), Chart: formatChartName(r.Chart), AppVersion: formatAppVersion(r.Chart), + Source: source, } t := "-" @@ -180,13 +189,13 @@ func newReleaseListWriter(releases []*release.Release, timeFormat string, noHead elements = append(elements, element) } - return &releaseListWriter{elements, noHeaders, noColor} + return &releaseListWriter{elements, noHeaders, noColor, showSource} } func (w *releaseListWriter) WriteTable(out io.Writer) error { table := uitable.New() if !w.noHeaders { - table.AddRow( + headers := []interface{}{ coloroutput.ColorizeHeader("NAME", w.noColor), coloroutput.ColorizeHeader("NAMESPACE", w.noColor), coloroutput.ColorizeHeader("REVISION", w.noColor), @@ -194,7 +203,11 @@ func (w *releaseListWriter) WriteTable(out io.Writer) error { coloroutput.ColorizeHeader("STATUS", w.noColor), coloroutput.ColorizeHeader("CHART", w.noColor), coloroutput.ColorizeHeader("APP VERSION", w.noColor), - ) + } + if w.showSource { + headers = append(headers, coloroutput.ColorizeHeader("SOURCE", w.noColor)) + } + table.AddRow(headers...) } for _, r := range w.releases { // Parse the status string back to a release.Status to use color @@ -221,7 +234,11 @@ func (w *releaseListWriter) WriteTable(out io.Writer) error { default: status = common.Status(r.Status) } - table.AddRow(r.Name, coloroutput.ColorizeNamespace(r.Namespace, w.noColor), r.Revision, r.Updated, coloroutput.ColorizeStatus(status, w.noColor), r.Chart, r.AppVersion) + row := []interface{}{r.Name, coloroutput.ColorizeNamespace(r.Namespace, w.noColor), r.Revision, r.Updated, coloroutput.ColorizeStatus(status, w.noColor), r.Chart, r.AppVersion} + if w.showSource { + row = append(row, r.Source) + } + table.AddRow(row...) } return output.EncodeTable(out, table) } diff --git a/pkg/cmd/list_test.go b/pkg/cmd/list_test.go index 35153465a..75bcb8382 100644 --- a/pkg/cmd/list_test.go +++ b/pkg/cmd/list_test.go @@ -38,6 +38,9 @@ func TestListCmd(t *testing.T) { Name: "chickadee", Version: "1.0.0", AppVersion: "0.0.1", + Annotations: map[string]string{ + "meta.helm.sh/release-source": "https://example.com/repo", + }, }, } @@ -234,6 +237,11 @@ func TestListCmd(t *testing.T) { cmd: "list -n milano", golden: "output/list-namespace.txt", rels: releaseFixture, + }, { + name: "list releases with the source column", + cmd: "list --show-source", + golden: "output/list-show-source.txt", + rels: releaseFixture, }} runTestCmd(t, tests) } @@ -254,6 +262,9 @@ func TestListOutputFormats(t *testing.T) { Name: "test-chart", Version: "1.0.0", AppVersion: "0.0.1", + Annotations: map[string]string{ + "meta.helm.sh/release-source": "https://example.com/repo", + }, }, } @@ -313,6 +324,7 @@ func TestReleaseListWriter(t *testing.T) { timeFormat string noHeaders bool noColor bool + showSource bool }{ { name: "empty releases list", @@ -342,11 +354,19 @@ func TestReleaseListWriter(t *testing.T) { noHeaders: false, noColor: true, }, + { + name: "show source", + releases: releases, + timeFormat: "", + noHeaders: false, + noColor: false, + showSource: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - writer := newReleaseListWriter(tt.releases, tt.timeFormat, tt.noHeaders, tt.noColor) + writer := newReleaseListWriter(tt.releases, tt.timeFormat, tt.noHeaders, tt.noColor, tt.showSource) if writer == nil { t.Error("Expected writer to be non-nil") @@ -423,7 +443,7 @@ func TestReleaseListWriterMethods(t *testing.T) { }, } - writer := newReleaseListWriter(testReleases, "", false, false) + writer := newReleaseListWriter(testReleases, "", false, false, false) var buf []byte out := &bytesWriter{buf: &buf} @@ -445,7 +465,7 @@ func TestReleaseListWriterMethods(t *testing.T) { }) } - writer := newReleaseListWriter(releases, "", false, false) + writer := newReleaseListWriter(releases, "", false, false, false) var buf []byte out := &bytesWriter{buf: &buf} @@ -604,7 +624,7 @@ func TestListStatusMapping(t *testing.T) { }, } - writer := newReleaseListWriter(releaseFixture, "", false, false) + writer := newReleaseListWriter(releaseFixture, "", false, false, false) if len(writer.releases) != 1 { t.Errorf("Expected 1 release, got %d", len(writer.releases)) } diff --git a/pkg/cmd/release_source.go b/pkg/cmd/release_source.go new file mode 100644 index 000000000..778b27e23 --- /dev/null +++ b/pkg/cmd/release_source.go @@ -0,0 +1,77 @@ +/* +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 cmd + +import ( + "net/url" + + v3chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/chart" + v2chart "helm.sh/helm/v4/pkg/chart/v2" +) + +// setReleaseSource records, on the chart's metadata, the location the chart was +// installed or upgraded from so it can be surfaced by 'helm list --show-source'. +// A repository URL (when supplied) is preferred over the local chart reference, +// and any embedded credentials are stripped before the value is persisted. +// +// It accepts the loader's chart.Charter (which may be either a v2 or v3 chart) +// and is a no-op for any unrecognized chart type, so callers never need to +// reason about the concrete chart version. +func setReleaseSource(chrt chart.Charter, chartRef, repoURL string) { + source := chartRef + if repoURL != "" { + source = repoURL + } + source = sanitizeChartSource(source) + + switch c := chrt.(type) { + case *v2chart.Chart: + if c.Metadata == nil { + c.Metadata = &v2chart.Metadata{} + } + if c.Metadata.Annotations == nil { + c.Metadata.Annotations = make(map[string]string) + } + c.Metadata.Annotations[action.ReleaseSourceAnnotation] = source + case *v3chart.Chart: + if c.Metadata == nil { + c.Metadata = &v3chart.Metadata{} + } + if c.Metadata.Annotations == nil { + c.Metadata.Annotations = make(map[string]string) + } + c.Metadata.Annotations[action.ReleaseSourceAnnotation] = source + } +} + +// sanitizeChartSource strips any user credentials and other potentially +// sensitive components (query string, fragment) from a chart source URL before +// it is persisted into the release record, so secrets embedded in a repo URL +// (e.g. https://user:pass@host) are not leaked into cluster metadata. Sources +// that are not URLs (local paths or chart references) are returned unchanged. +func sanitizeChartSource(source string) string { + u, err := url.Parse(source) + if err != nil || u.Scheme == "" { + return source + } + u.User = nil + u.RawQuery = "" + u.Fragment = "" + return u.String() +} diff --git a/pkg/cmd/release_source_test.go b/pkg/cmd/release_source_test.go new file mode 100644 index 000000000..60c0a03ee --- /dev/null +++ b/pkg/cmd/release_source_test.go @@ -0,0 +1,86 @@ +/* +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 cmd + +import "testing" + +func TestSanitizeChartSource(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + { + name: "strips userinfo from https URL", + source: "https://user:pass@charts.example.com/mychart", + want: "https://charts.example.com/mychart", + }, + { + name: "strips username-only userinfo", + source: "https://user@charts.example.com/mychart", + want: "https://charts.example.com/mychart", + }, + { + name: "strips query string and fragment", + source: "https://charts.example.com/index.yaml?token=secret#section", + want: "https://charts.example.com/index.yaml", + }, + { + name: "strips userinfo, query and fragment together", + source: "https://user:pass@charts.example.com/index.yaml?token=secret#frag", + want: "https://charts.example.com/index.yaml", + }, + { + name: "strips userinfo from oci URL", + source: "oci://user:pass@registry.example.com/charts/mychart", + want: "oci://registry.example.com/charts/mychart", + }, + { + name: "leaves credential-free https URL unchanged", + source: "https://charts.example.com/mychart", + want: "https://charts.example.com/mychart", + }, + { + name: "leaves chart reference unchanged", + source: "stable/mychart", + want: "stable/mychart", + }, + { + name: "leaves relative local path unchanged", + source: "./charts/mychart", + want: "./charts/mychart", + }, + { + name: "leaves bare chart name unchanged", + source: "mychart", + want: "mychart", + }, + { + name: "leaves empty source unchanged", + source: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeChartSource(tt.source); got != tt.want { + t.Errorf("sanitizeChartSource(%q) = %q, want %q", tt.source, got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/testdata/output/list-json.txt b/pkg/cmd/testdata/output/list-json.txt index 89e4d9dcf..32fbc6a58 100644 --- a/pkg/cmd/testdata/output/list-json.txt +++ b/pkg/cmd/testdata/output/list-json.txt @@ -1 +1 @@ -[{"name":"test-release","namespace":"default","revision":"1","updated":"2016-01-16 00:00:00 +0000 UTC","status":"deployed","chart":"test-chart-1.0.0","app_version":"0.0.1"}] +[{"name":"test-release","namespace":"default","revision":"1","updated":"2016-01-16 00:00:00 +0000 UTC","status":"deployed","chart":"test-chart-1.0.0","app_version":"0.0.1","source":"https://example.com/repo"}] diff --git a/pkg/cmd/testdata/output/list-show-source.txt b/pkg/cmd/testdata/output/list-show-source.txt new file mode 100644 index 000000000..609d7c786 --- /dev/null +++ b/pkg/cmd/testdata/output/list-show-source.txt @@ -0,0 +1,9 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION SOURCE +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 https://example.com/repo +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 https://example.com/repo +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 https://example.com/repo +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 https://example.com/repo +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 https://example.com/repo +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 https://example.com/repo +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 https://example.com/repo +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 https://example.com/repo diff --git a/pkg/cmd/testdata/output/list-yaml.txt b/pkg/cmd/testdata/output/list-yaml.txt index 9e1d41f30..1ff5418ee 100644 --- a/pkg/cmd/testdata/output/list-yaml.txt +++ b/pkg/cmd/testdata/output/list-yaml.txt @@ -3,5 +3,6 @@ name: test-release namespace: default revision: "1" + source: https://example.com/repo status: deployed updated: 2016-01-16 00:00:00 +0000 UTC diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 9ec4f4ef2..6afe01666 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -250,6 +250,11 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { cancel() }() + // Record where the chart was upgraded from so 'helm list --show-source' + // can surface it later. This is done after any dependency-update reload + // above so the annotation survives the chart being reloaded from disk. + setReleaseSource(ch, args[1], client.RepoURL) + rel, err := client.RunWithContext(ctx, args[0], ch, vals) if err != nil { return fmt.Errorf("UPGRADE FAILED: %w", err)