From fc2a5e3fec95f05e1afa5af9cc547e346c785a4e Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Mon, 22 Jun 2026 02:18:59 +0530 Subject: [PATCH 1/5] 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) From 2cf9b05ced51943e684c3360eb64e06d94dbd44c Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Mon, 22 Jun 2026 12:26:44 +0530 Subject: [PATCH 2/5] fix(cmd): do not rewrite local paths as URLs in sanitizeChartSource url.Parse treats a Windows absolute path such as C:\charts\mychart as having scheme "c", so sanitizeChartSource re-encoded it (e.g. the drive letter was lowercased and separators could be percent-escaped), producing a misleading SOURCE value for local installs/upgrades. Gate sanitization on the parsed URL having a host component, so only real repository URLs (scheme://host/...) are rewritten while local filesystem paths and chart references are returned unchanged. Add regression tests covering Windows (back- and forward-slash) and absolute unix paths. Signed-off-by: Shaan Satsangi --- pkg/cmd/release_source.go | 7 ++++++- pkg/cmd/release_source_test.go | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/release_source.go b/pkg/cmd/release_source.go index 778b27e23..0bd00df03 100644 --- a/pkg/cmd/release_source.go +++ b/pkg/cmd/release_source.go @@ -65,9 +65,14 @@ func setReleaseSource(chrt chart.Charter, chartRef, repoURL string) { // 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. +// +// Only sources with a host component (i.e. scheme://host/...) are treated as +// URLs. This deliberately excludes local filesystem paths, including Windows +// absolute paths such as C:\charts\mychart, which url.Parse would otherwise +// interpret as a "c" scheme and re-encode (e.g. c:%5Ccharts%5Cmychart). func sanitizeChartSource(source string) string { u, err := url.Parse(source) - if err != nil || u.Scheme == "" { + if err != nil || u.Scheme == "" || u.Host == "" { return source } u.User = nil diff --git a/pkg/cmd/release_source_test.go b/pkg/cmd/release_source_test.go index 60c0a03ee..9dfd2c255 100644 --- a/pkg/cmd/release_source_test.go +++ b/pkg/cmd/release_source_test.go @@ -64,6 +64,21 @@ func TestSanitizeChartSource(t *testing.T) { source: "./charts/mychart", want: "./charts/mychart", }, + { + name: "leaves absolute unix path unchanged", + source: "/var/charts/mychart", + want: "/var/charts/mychart", + }, + { + name: "leaves windows absolute path unchanged", + source: `C:\charts\mychart`, + want: `C:\charts\mychart`, + }, + { + name: "leaves windows forward-slash path unchanged", + source: "C:/charts/mychart", + want: "C:/charts/mychart", + }, { name: "leaves bare chart name unchanged", source: "mychart", From 57667c932ca1e9bcf24945a8da035c2ee4a26a72 Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Tue, 23 Jun 2026 01:59:44 +0530 Subject: [PATCH 3/5] fix(cmd): correct --show-source help text and cover get metadata source The --show-source help claimed JSON/YAML output 'always includes' the source field, but releaseElement.Source is tagged json:"source,omitempty" so it is omitted when a release has no recorded source. Reword the help to state the flag affects only table output and that JSON/YAML emit source whenever a release has one. Also add cmd-level get metadata coverage for a release whose chart records meta.helm.sh/release-source, with table/JSON/YAML golden files, so the new SOURCE line and source field do not regress. Signed-off-by: Shaan Satsangi --- pkg/cmd/get_metadata_test.go | 25 +++++++++++++++++++ pkg/cmd/list.go | 2 +- .../testdata/output/get-metadata-source.json | 1 + .../testdata/output/get-metadata-source.txt | 13 ++++++++++ .../testdata/output/get-metadata-source.yaml | 25 +++++++++++++++++++ 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/testdata/output/get-metadata-source.json create mode 100644 pkg/cmd/testdata/output/get-metadata-source.txt create mode 100644 pkg/cmd/testdata/output/get-metadata-source.yaml diff --git a/pkg/cmd/get_metadata_test.go b/pkg/cmd/get_metadata_test.go index 59fc3b82c..6f0b307b7 100644 --- a/pkg/cmd/get_metadata_test.go +++ b/pkg/cmd/get_metadata_test.go @@ -19,15 +19,40 @@ package cmd import ( "testing" + "helm.sh/helm/v4/pkg/action" release "helm.sh/helm/v4/pkg/release/v1" ) +// mockReleaseWithSource returns a mock release whose chart records a +// meta.helm.sh/release-source annotation, so the 'get metadata' output for the +// new SOURCE field can be exercised. +func mockReleaseWithSource(name string) *release.Release { + rel := release.Mock(&release.MockReleaseOptions{Name: name, Labels: map[string]string{"key1": "value1"}}) + rel.Chart.Metadata.Annotations[action.ReleaseSourceAnnotation] = "https://charts.example.com" + return rel +} + 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", Labels: map[string]string{"key1": "value1"}})}, + }, { + name: "get metadata with a recorded source", + cmd: "get metadata thomas-guide", + golden: "output/get-metadata-source.txt", + rels: []*release.Release{mockReleaseWithSource("thomas-guide")}, + }, { + name: "get metadata with a recorded source to json", + cmd: "get metadata thomas-guide --output json", + golden: "output/get-metadata-source.json", + rels: []*release.Release{mockReleaseWithSource("thomas-guide")}, + }, { + name: "get metadata with a recorded source to yaml", + cmd: "get metadata thomas-guide --output yaml", + golden: "output/get-metadata-source.yaml", + rels: []*release.Release{mockReleaseWithSource("thomas-guide")}, }, { name: "get metadata requires release name arg", cmd: "get metadata", diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 8cad89c9e..907c5fb02 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -135,7 +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") + 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); this flag affects only table output, while JSON/YAML output includes the 'source' field whenever a release has a recorded source") bindOutputFlag(cmd, &outfmt) return cmd diff --git a/pkg/cmd/testdata/output/get-metadata-source.json b/pkg/cmd/testdata/output/get-metadata-source.json new file mode 100644 index 000000000..a850fcea2 --- /dev/null +++ b/pkg/cmd/testdata/output/get-metadata-source.json @@ -0,0 +1 @@ +{"name":"thomas-guide","chart":"foo","version":"0.1.0-beta.1","appVersion":"1.0","source":"https://charts.example.com","annotations":{"category":"web-apps","meta.helm.sh/release-source":"https://charts.example.com","supported":"true"},"labels":{"key1":"value1"},"dependencies":[{"name":"cool-plugin","version":"1.0.0","repository":"https://coolplugin.io/charts","condition":"coolPlugin.enabled","enabled":true},{"name":"crds","version":"2.7.1","repository":"","condition":"crds.enabled"}],"namespace":"default","revision":1,"status":"deployed","deployedAt":"1977-09-02T22:04:05Z"} diff --git a/pkg/cmd/testdata/output/get-metadata-source.txt b/pkg/cmd/testdata/output/get-metadata-source.txt new file mode 100644 index 000000000..24a87b06c --- /dev/null +++ b/pkg/cmd/testdata/output/get-metadata-source.txt @@ -0,0 +1,13 @@ +NAME: thomas-guide +CHART: foo +VERSION: 0.1.0-beta.1 +APP_VERSION: 1.0 +SOURCE: https://charts.example.com +ANNOTATIONS: category=web-apps,meta.helm.sh/release-source=https://charts.example.com,supported=true +LABELS: key1=value1 +DEPENDENCIES: cool-plugin,crds +NAMESPACE: default +REVISION: 1 +STATUS: deployed +DEPLOYED_AT: 1977-09-02T22:04:05Z +APPLY_METHOD: client-side apply (defaulted) diff --git a/pkg/cmd/testdata/output/get-metadata-source.yaml b/pkg/cmd/testdata/output/get-metadata-source.yaml new file mode 100644 index 000000000..796270684 --- /dev/null +++ b/pkg/cmd/testdata/output/get-metadata-source.yaml @@ -0,0 +1,25 @@ +annotations: + category: web-apps + meta.helm.sh/release-source: https://charts.example.com + supported: "true" +appVersion: "1.0" +chart: foo +dependencies: +- condition: coolPlugin.enabled + enabled: true + name: cool-plugin + repository: https://coolplugin.io/charts + version: 1.0.0 +- condition: crds.enabled + name: crds + repository: "" + version: 2.7.1 +deployedAt: "1977-09-02T22:04:05Z" +labels: + key1: value1 +name: thomas-guide +namespace: default +revision: 1 +source: https://charts.example.com +status: deployed +version: 0.1.0-beta.1 From 7f8dc218137be16f36b04ece649aa06cd64444c8 Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Tue, 23 Jun 2026 04:59:39 +0530 Subject: [PATCH 4/5] fix(action): apply release-source annotation after rendering Previously the chart source was recorded by mutating the chart's metadata annotations in pkg/cmd before calling RunWithContext. Because the render values expose chart metadata to templates (.Chart comes from accessor.MetadataAsMap()), this injected meta.helm.sh/release-source into .Chart.Annotations during template rendering, which could change rendered manifests for charts that enumerate or branch on chart annotations. Move the annotation to the action layer and apply it only after rendering completes, via a new Install.ChartSource / Upgrade.ChartSource field. The annotation lands on the persisted release's chart metadata (the same chart pointer stored on the release) but is never present in the template render context, so 'helm list --show-source' and 'helm get metadata' still surface the source while rendered output is unaffected. The cmd helper is reduced to a pure chartSource() resolver (repo URL preferred, credentials stripped); sanitizeChartSource is unchanged. Adds an action-level test asserting the source is absent from the rendered manifest but present on the stored release. Signed-off-by: Shaan Satsangi --- pkg/action/get_metadata.go | 18 ++++++++++++++++ pkg/action/install.go | 13 ++++++++++++ pkg/action/install_test.go | 31 ++++++++++++++++++++++++++++ pkg/action/upgrade.go | 14 +++++++++++++ pkg/cmd/install.go | 7 ++++--- pkg/cmd/release_source.go | 42 ++++++++------------------------------ pkg/cmd/upgrade.go | 7 ++++--- 7 files changed, 92 insertions(+), 40 deletions(-) diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go index 3754811eb..cc5705143 100644 --- a/pkg/action/get_metadata.go +++ b/pkg/action/get_metadata.go @@ -33,6 +33,24 @@ import ( // can be surfaced by 'helm list --show-source' and 'helm get metadata'. const ReleaseSourceAnnotation = "meta.helm.sh/release-source" +// setChartSourceAnnotation records source on the chart's metadata under +// ReleaseSourceAnnotation. It is a no-op when source is empty. Callers must +// invoke it only after render values have been computed/rendered, so the +// annotation is persisted on the stored release without leaking into the +// template render context via .Chart.Annotations. +func setChartSourceAnnotation(chrt *chart.Chart, source string) { + if source == "" { + return + } + if chrt.Metadata == nil { + chrt.Metadata = &chart.Metadata{} + } + if chrt.Metadata.Annotations == nil { + chrt.Metadata.Annotations = make(map[string]string) + } + chrt.Metadata.Annotations[ReleaseSourceAnnotation] = source +} + // GetMetadata is the action for checking a given release's metadata. // // It provides the implementation of 'helm get metadata'. diff --git a/pkg/action/install.go b/pkg/action/install.go index fcfae7214..457292d23 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -76,6 +76,13 @@ type Install struct { ChartPathOptions + // ChartSource records where the chart was obtained from (a repository URL or + // a local chart reference). When non-empty it is persisted on the release's + // chart metadata under ReleaseSourceAnnotation after rendering, so it is + // surfaced by 'helm list --show-source' and 'helm get metadata' without ever + // being exposed to templates via .Chart.Annotations. + ChartSource string + // ForceReplace will, if set to `true`, ignore certain warnings and perform the install anyway. // // This should be used with caution. @@ -387,6 +394,12 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return rel, err } + // Record the chart source on the persisted release. This is done after + // rendering so the annotation is not visible to templates via + // .Chart.Annotations, while still being stored on rel.Chart (which shares + // the same chart pointer) for 'helm list'/'helm get metadata'. + setChartSourceAnnotation(chrt, i.ChartSource) + // Mark this release as in-progress rel.SetStatus(rcommon.StatusPendingInstall, "Initial install underway") diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 705a8e11d..723600ffa 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -220,6 +220,37 @@ func TestInstallRelease(t *testing.T) { is.Equal(lrel.Info.Status, rcommon.StatusDeployed) } +func TestInstallReleaseRecordsChartSourceAfterRender(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + instAction := installAction(t) + instAction.ChartSource = "https://charts.example.com/repo" + + // This template renders the chart's annotations. If the source were recorded + // before rendering, it would leak into the manifest via .Chart.Annotations. + tmpl := []*common.File{ + {Name: "templates/cfg", ModTime: time.Now(), Data: []byte("annotations: {{ .Chart.Annotations }}")}, + } + resi, err := instAction.RunWithContext(t.Context(), buildChartWithTemplates(tmpl), map[string]any{}) + req.NoError(err) + res, err := releaserToV1Release(resi) + req.NoError(err) + + // The rendered manifest must not contain the recorded source: the annotation + // is applied only after rendering, so it never reaches .Chart.Annotations. + is.NotContains(res.Manifest, "https://charts.example.com/repo") + is.NotContains(res.Manifest, ReleaseSourceAnnotation) + + // The persisted release, however, must carry the source annotation so it can + // be surfaced by 'helm list --show-source' and 'helm get metadata'. + stored, err := instAction.cfg.Releases.Get(res.Name, res.Version) + req.NoError(err) + rel, err := releaserToV1Release(stored) + req.NoError(err) + is.Equal("https://charts.example.com/repo", rel.Chart.Metadata.Annotations[ReleaseSourceAnnotation]) +} + func TestInstallReleaseWithTakeOwnership_ResourceNotOwned(t *testing.T) { // This test will test checking ownership of a resource // returned by the fake client. If the resource is not diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 81d51164f..e2c013261 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -52,6 +52,13 @@ type Upgrade struct { ChartPathOptions + // ChartSource records where the chart was obtained from (a repository URL or + // a local chart reference). When non-empty it is persisted on the upgraded + // release's chart metadata under ReleaseSourceAnnotation after rendering, so + // it is surfaced by 'helm list --show-source' and 'helm get metadata' + // without ever being exposed to templates via .Chart.Annotations. + ChartSource string + // Install is a purely informative flag that indicates whether this upgrade was done in "install" mode. // // Applications may use this to determine whether this Upgrade operation was done as part of a @@ -305,6 +312,13 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv return nil, nil, false, err } + // Record the chart source on the persisted release. This is done after + // rendering so the annotation is not visible to templates via + // .Chart.Annotations, while still being stored on the upgraded release's + // chart (which shares the same chart pointer) for 'helm list'/'helm get + // metadata'. + setChartSourceAnnotation(chart, u.ChartSource) + if driver.ContainsSystemLabels(u.Labels) { return nil, nil, false, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()) } diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 801527f91..e2a350d03 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -345,9 +345,10 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options }() // 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) + // surface it later. The action applies this after rendering, so it is never + // exposed to templates via .Chart.Annotations. Resolved after any + // dependency-update reload above so it reflects the chart actually installed. + client.ChartSource = chartSource(chartRef, client.RepoURL) ri, err := client.RunWithContext(ctx, chartRequested, vals) rel, rerr := releaserToV1Release(ri) diff --git a/pkg/cmd/release_source.go b/pkg/cmd/release_source.go index 0bd00df03..a934fa0a4 100644 --- a/pkg/cmd/release_source.go +++ b/pkg/cmd/release_source.go @@ -18,46 +18,20 @@ 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) { +// chartSource resolves the location a chart was installed or upgraded from, for +// persistence under action.ReleaseSourceAnnotation. A repository URL (when +// supplied) is preferred over the local chart reference, and any embedded +// credentials are stripped before the value is returned. The result is handed +// to the install/upgrade action, which records it on the release after +// rendering so it is never exposed to templates via .Chart.Annotations. +func chartSource(chartRef, repoURL string) 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 - } + return sanitizeChartSource(source) } // sanitizeChartSource strips any user credentials and other potentially diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 6afe01666..b3efbc81c 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -251,9 +251,10 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }() // 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) + // can surface it later. The action applies this after rendering, so it + // is never exposed to templates via .Chart.Annotations. Resolved after + // any dependency-update reload above so it reflects the actual chart. + client.ChartSource = chartSource(args[1], client.RepoURL) rel, err := client.RunWithContext(ctx, args[0], ch, vals) if err != nil { From c9270177f0316086ce0afb08faca9b2412a26d57 Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Tue, 23 Jun 2026 07:21:01 +0530 Subject: [PATCH 5/5] test(action): cover upgrade source annotation; clarify metadata comment Address the follow-up review on the previous revision: - Add TestUpgradeReleaseRecordsChartSourceAfterRender, mirroring the install regression test: it upgrades a release with a template that prints .Chart.Annotations and asserts the recorded source is absent from the rendered manifest but present on the persisted upgraded release. This guards the upgrade path against a future refactor moving the annotation back before rendering and reintroducing the render-context leak. - Correct the Metadata.Annotations doc comment in 'helm get metadata': the map is no longer solely from Chart.yaml; for releases recorded with a source it also includes the meta.helm.sh/release-source annotation Helm adds at install/upgrade time (also surfaced separately via Source). Signed-off-by: Shaan Satsangi --- pkg/action/get_metadata.go | 5 ++++- pkg/action/install_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go index cc5705143..ab407592d 100644 --- a/pkg/action/get_metadata.go +++ b/pkg/action/get_metadata.go @@ -69,7 +69,10 @@ type Metadata struct { // 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 are the chart metadata annotations from Chart.yaml. For a + // release installed or upgraded with a recorded source, this map also + // includes the meta.helm.sh/release-source annotation Helm adds at + // install/upgrade time (also surfaced separately via Source above). Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` // Labels of the release which are stored in driver metadata fields storage Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 723600ffa..52d8a34e9 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -251,6 +251,41 @@ func TestInstallReleaseRecordsChartSourceAfterRender(t *testing.T) { is.Equal("https://charts.example.com/repo", rel.Chart.Metadata.Annotations[ReleaseSourceAnnotation]) } +func TestUpgradeReleaseRecordsChartSourceAfterRender(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + upAction := upgradeAction(t) + rel := releaseStub() + rel.Name = "previous-release" + rel.Info.Status = rcommon.StatusDeployed + req.NoError(upAction.cfg.Releases.Create(rel)) + + upAction.ChartSource = "https://charts.example.com/repo" + + // Same probe as the install path: a template that renders the chart's + // annotations. If the source were recorded before rendering, it would leak + // into the manifest via .Chart.Annotations. + tmpl := []*common.File{ + {Name: "templates/cfg", ModTime: time.Now(), Data: []byte("annotations: {{ .Chart.Annotations }}")}, + } + resi, err := upAction.RunWithContext(t.Context(), rel.Name, buildChartWithTemplates(tmpl), map[string]any{}) + req.NoError(err) + res, err := releaserToV1Release(resi) + req.NoError(err) + + // The recorded source must not appear in the rendered manifest. + is.NotContains(res.Manifest, "https://charts.example.com/repo") + is.NotContains(res.Manifest, ReleaseSourceAnnotation) + + // The persisted upgraded release must carry the source annotation. + stored, err := upAction.cfg.Releases.Last(rel.Name) + req.NoError(err) + last, err := releaserToV1Release(stored) + req.NoError(err) + is.Equal("https://charts.example.com/repo", last.Chart.Metadata.Annotations[ReleaseSourceAnnotation]) +} + func TestInstallReleaseWithTakeOwnership_ResourceNotOwned(t *testing.T) { // This test will test checking ownership of a resource // returned by the fake client. If the resource is not