feat: record and show installed chart source location

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 <shaansatsangi@gmail.com>
pull/32250/head
Shaan Satsangi 4 weeks ago
parent 74fa4fceb8
commit fc2a5e3fec

@ -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(),

@ -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)

@ -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())

@ -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 {

@ -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)
}

@ -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))
}

@ -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()
}

@ -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)
}
})
}
}

@ -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"}]

@ -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

@ -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

@ -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)

Loading…
Cancel
Save