diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go index 7d5a3f5f9..ab407592d 100644 --- a/pkg/action/get_metadata.go +++ b/pkg/action/get_metadata.go @@ -28,6 +28,29 @@ 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" + +// 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'. @@ -42,7 +65,14 @@ type Metadata struct { Chart string `json:"chart" yaml:"chart"` Version string `json:"version" yaml:"version"` AppVersion string `json:"appVersion" yaml:"appVersion"` - // Annotations are fetched from the Chart.yaml file + // 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 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"` @@ -98,6 +128,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/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..52d8a34e9 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -220,6 +220,72 @@ 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 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 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/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/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/install.go b/pkg/cmd/install.go index 832968ff0..e2a350d03 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -344,6 +344,12 @@ 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. 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) if rerr != nil { diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 54becddca..907c5fb02 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); 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 @@ -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..a934fa0a4 --- /dev/null +++ b/pkg/cmd/release_source.go @@ -0,0 +1,56 @@ +/* +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" +) + +// 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 + } + return sanitizeChartSource(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. +// +// 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 == "" || u.Host == "" { + 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..9dfd2c255 --- /dev/null +++ b/pkg/cmd/release_source_test.go @@ -0,0 +1,101 @@ +/* +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 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", + 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/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 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..b3efbc81c 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -250,6 +250,12 @@ 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. 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 { return fmt.Errorf("UPGRADE FAILED: %w", err)