diff --git a/internal/chart/v3/metadata.go b/internal/chart/v3/metadata.go index 5f7cea897..3611dd653 100644 --- a/internal/chart/v3/metadata.go +++ b/internal/chart/v3/metadata.go @@ -81,6 +81,9 @@ type Metadata struct { Dependencies []*Dependency `json:"dependencies,omitempty"` // Specifies the chart type: application or library Type string `json:"type,omitempty"` + // The URL from which the chart was obtained (a Helm repository URL, + // an OCI reference, or a direct artifact URL). Used to record provenance. + RepoURL string `json:"repoURL,omitempty"` } // Validate checks the metadata for known issues and sanitizes string @@ -98,6 +101,7 @@ func (md *Metadata) Validate() error { md.Tags = sanitizeString(md.Tags) md.AppVersion = sanitizeString(md.AppVersion) md.KubeVersion = sanitizeString(md.KubeVersion) + md.RepoURL = sanitizeString(md.RepoURL) for i := range md.Sources { md.Sources[i] = sanitizeString(md.Sources[i]) } diff --git a/pkg/action/install.go b/pkg/action/install.go index 6fc919366..f2aa15438 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -157,6 +157,19 @@ type ChartPathOptions struct { // registryClient provides a registry client but is not added with // options from a flag registryClient *registry.Client + + // resolvedRepoURL is the source URL from which the chart was obtained + // during the last LocateChart call. It is independent of RepoURL + // (the --repo input) and is used to record provenance into chart metadata. + resolvedRepoURL string +} + +// ResolvedRepoURL returns the source URL from which the chart was obtained +// during the last LocateChart call (a Helm repository URL, an OCI reference, +// or a direct artifact URL). It is empty for local-path installs or when +// LocateChart has not been called. +func (c *ChartPathOptions) ResolvedRepoURL() string { + return c.resolvedRepoURL } // NewInstall creates a new Install object with the given configuration. @@ -372,6 +385,18 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return nil, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()) } + // Stamp provenance information into the chart metadata. Prefer the URL + // discovered by LocateChart (ResolvedRepoURL) and fall back to the --repo + // input for library consumers who set it without calling LocateChart. + // Do not overwrite a value already set in Chart.yaml. + if chrt.Metadata != nil && chrt.Metadata.RepoURL == "" { + if u := i.ChartPathOptions.ResolvedRepoURL(); u != "" { + chrt.Metadata.RepoURL = u + } else if i.ChartPathOptions.RepoURL != "" { + chrt.Metadata.RepoURL = i.ChartPathOptions.RepoURL + } + } + rel := i.createRelease(chrt, vals, i.Labels) var manifestDoc *bytes.Buffer @@ -876,6 +901,20 @@ func urlEqual(u1, u2 *url.URL) bool { return u1.Scheme == u2.Scheme && u1.Hostname() == u2.Hostname() && portOrDefault(u1) == portOrDefault(u2) } +// isRemoteChartRef reports whether ref points to a remote chart (an OCI +// reference or an absolute http(s) URL), as opposed to a local path or a +// repo-by-name reference. +func isRemoteChartRef(ref string) bool { + if registry.IsOCI(ref) { + return true + } + u, err := url.Parse(ref) + if err != nil { + return false + } + return u.IsAbs() && (u.Scheme == "http" || u.Scheme == "https") +} + // LocateChart looks for a chart directory in known places, and returns either the full path or an error. // // This does not ensure that the chart is well-formed; only that the requested filename exists. @@ -891,6 +930,10 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) ( return "", fmt.Errorf("unable to lookup chart %q, missing registry client", name) } + // Reset any provenance recorded by a previous call so callers that reuse + // this instance do not observe stale values from an earlier chart. + c.resolvedRepoURL = "" + name = strings.TrimSpace(name) version := strings.TrimSpace(c.Version) @@ -984,6 +1027,22 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) ( return "", err } + // Record provenance information about where the chart was resolved from. + // Prefer the URL discovered by the downloader, then the --repo input, + // then the original reference when it is an OCI ref or an absolute URL. + // Local paths and unresolvable references leave resolvedRepoURL empty so + // callers can distinguish them. We deliberately do not mutate RepoURL, + // because it is also the --repo flag input and controls local-path vs + // remote resolution on subsequent calls. + switch { + case dl.RepositoryURL() != "": + c.resolvedRepoURL = dl.RepositoryURL() + case c.RepoURL != "": + c.resolvedRepoURL = c.RepoURL + case isRemoteChartRef(name): + c.resolvedRepoURL = name + } + lname, err := filepath.Abs(filename) if err != nil { return filename, err diff --git a/pkg/action/repourl2_test.go b/pkg/action/repourl2_test.go new file mode 100644 index 000000000..676999e88 --- /dev/null +++ b/pkg/action/repourl2_test.go @@ -0,0 +1,62 @@ +package action + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/chart/v2/loader" + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" +) + +func TestRenderRepoURL2_ValidAndInvalid(t *testing.T) { + cfg := actionConfigFixture(t) + + tests := []struct { + name string + chartPath string + expectPart string + }{ + { + name: "valid repoURL", + chartPath: "testdata/charts/chart-with-repourl-valid", + expectPart: `repoURL: "https://example.com/charts"`, + }, + { + name: "invalid repoURL", + chartPath: "testdata/charts/chart-with-repourl-invalid", + expectPart: `repoURL: "ht!tp://not-a-valid-url"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch, err := loader.Load(tt.chartPath) + require.NoError(t, err) + if ch.Metadata == nil { + md, err := chartutil.LoadChartfile(filepath.Join(tt.chartPath, "Chart.yaml")) + require.NoError(t, err) + ch.Metadata = md + } + + _, buf, _, err := cfg.renderResources( + ch, + map[string]interface{}{}, + "test-release", + "", + false, + false, + false, + nil, + false, + false, + false, + ) + require.NoError(t, err) + require.NotNil(t, buf) + assert.Contains(t, buf.String(), tt.expectPart) + }) + } +} diff --git a/pkg/action/testdata/charts/chart-with-repourl-invalid/Chart.yaml b/pkg/action/testdata/charts/chart-with-repourl-invalid/Chart.yaml new file mode 100644 index 000000000..89a08d5ef --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-repourl-invalid/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: chart-with-repourl-invalid +version: 0.1.0 +description: Test chart containing an invalid repoURL in metadata +repoURL: "ht!tp://not-a-valid-url" diff --git a/pkg/action/testdata/charts/chart-with-repourl-invalid/templates/configmap.yaml b/pkg/action/testdata/charts/chart-with-repourl-invalid/templates/configmap.yaml new file mode 100644 index 000000000..ec74e9cb4 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-repourl-invalid/templates/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: chart-with-repourl-invalid-cm +data: + repoURL: {{ .Chart.RepoURL | quote }} diff --git a/pkg/action/testdata/charts/chart-with-repourl-valid/Chart.yaml b/pkg/action/testdata/charts/chart-with-repourl-valid/Chart.yaml new file mode 100644 index 000000000..7f4d9e0bf --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-repourl-valid/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: chart-with-repourl-valid +version: 0.1.0 +description: Test chart containing a valid repoURL in metadata +repoURL: https://example.com/charts diff --git a/pkg/action/testdata/charts/chart-with-repourl-valid/templates/configmap.yaml b/pkg/action/testdata/charts/chart-with-repourl-valid/templates/configmap.yaml new file mode 100644 index 000000000..2629a1021 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-repourl-valid/templates/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: chart-with-repourl-valid-cm +data: + repoURL: {{ .Chart.RepoURL | quote }} diff --git a/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml b/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml index b27de2754..7f348df6a 100644 --- a/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml +++ b/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 name: multiplecharts-lint-chart-2 version: "1" -icon: "" \ No newline at end of file +icon: "" diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 85904124b..83683d012 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -315,6 +315,18 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv u.cfg.Logger().Debug("determined release apply method", slog.Bool("server_side_apply", serverSideApply), slog.String("previous_release_apply_method", lastRelease.ApplyMethod)) + // Stamp provenance information into the chart metadata. Prefer the URL + // discovered by LocateChart (ResolvedRepoURL) and fall back to the --repo + // input for library consumers who set it without calling LocateChart. + // Do not overwrite a value already set in Chart.yaml. + if chart.Metadata != nil && chart.Metadata.RepoURL == "" { + if r := u.ChartPathOptions.ResolvedRepoURL(); r != "" { + chart.Metadata.RepoURL = r + } else if u.ChartPathOptions.RepoURL != "" { + chart.Metadata.RepoURL = u.ChartPathOptions.RepoURL + } + } + // Store an upgraded release. upgradedRelease := &release.Release{ Name: name, diff --git a/pkg/chart/v2/metadata.go b/pkg/chart/v2/metadata.go index 5df307321..a4078efde 100644 --- a/pkg/chart/v2/metadata.go +++ b/pkg/chart/v2/metadata.go @@ -52,6 +52,9 @@ type Metadata struct { Home string `json:"home,omitempty"` // Source is the URL to the source code of this chart Sources []string `json:"sources,omitempty"` + // The URL from which the chart was obtained (a Helm repository URL, + // an OCI reference, or a direct artifact URL). Used to record provenance. + RepoURL string `json:"repoURL,omitempty"` // A version string of the chart. Required. Version string `json:"version,omitempty"` // A one-sentence description of the chart @@ -93,6 +96,7 @@ func (md *Metadata) Validate() error { md.Name = sanitizeString(md.Name) md.Description = sanitizeString(md.Description) md.Home = sanitizeString(md.Home) + md.RepoURL = sanitizeString(md.RepoURL) md.Icon = sanitizeString(md.Icon) md.Condition = sanitizeString(md.Condition) md.Tags = sanitizeString(md.Tags) diff --git a/pkg/downloader/chart_downloader.go b/pkg/downloader/chart_downloader.go index 712c80ad2..eb7c7249b 100644 --- a/pkg/downloader/chart_downloader.go +++ b/pkg/downloader/chart_downloader.go @@ -85,6 +85,15 @@ type ChartDownloader struct { // Cache specifies the cache implementation to use. Cache Cache + + // repositoryURL is the resolved Helm repository URL, set by + // ResolveChartVersion when the chart is looked up via a configured repo. + repositoryURL string +} + +// RepositoryURL returns the resolved Helm repository URL, if any. +func (c *ChartDownloader) RepositoryURL() string { + return c.repositoryURL } // DownloadTo retrieves a chart. Depending on the settings, it may also download a provenance file. @@ -363,6 +372,11 @@ func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provena // // TODO: support OCI hash func (c *ChartDownloader) ResolveChartVersion(ref, version string) (string, *url.URL, error) { + // Clear any URL recorded by a previous call so RepositoryURL() does not + // report stale provenance when the same ChartDownloader instance is + // reused for a chart that does not resolve to a configured repo. + c.repositoryURL = "" + u, err := url.Parse(ref) if err != nil { return "", nil, fmt.Errorf("invalid chart URL format: %s", ref) @@ -403,6 +417,9 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (string, *url // If we get here, we don't need to go through the next phase of looking // up the URL. We have it already. So we just set the parameters and return. + if rc.URL != "" { + c.repositoryURL = rc.URL + } c.Options = append( c.Options, getter.WithURL(rc.URL), @@ -443,6 +460,9 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (string, *url } if r != nil && r.Config != nil { + if r.Config.URL != "" { + c.repositoryURL = r.Config.URL + } if r.Config.CertFile != "" || r.Config.KeyFile != "" || r.Config.CAFile != "" { c.Options = append(c.Options, getter.WithTLSClientConfig(r.Config.CertFile, r.Config.KeyFile, r.Config.CAFile)) }