Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 107 additions & 2 deletions bindings/go/helm/internal/download/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"path"
"path/filepath"
"strings"

"helm.sh/helm/v4/pkg/chart/v2/loader"
"helm.sh/helm/v4/pkg/downloader"
"helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/registry"
helmrepo "helm.sh/helm/v4/pkg/repo/v1"

"ocm.software/open-component-model/bindings/go/blob/filesystem"
"ocm.software/open-component-model/bindings/go/helm/internal"
Expand Down Expand Up @@ -132,12 +136,17 @@ func NewReadOnlyChartFromRemote(ctx context.Context, helmRepo, targetDir string,
dl.Options = append(dl.Options, getter.WithBasicAuth(username, password))
}

version, err := getVersion(opt.Version, helmRepo)
resolvedRepo, err := resolveHTTPChartURL(ctx, helmRepo, opt.Version, targetDir, GetterProviders(), getterOpts)
if err != nil {
return nil, fmt.Errorf("error resolving chart URL %q via index.yaml: %w", helmRepo, err)
}

version, err := getVersion(opt.Version, resolvedRepo)
if err != nil {
return nil, fmt.Errorf("error determining chart version: %w", err)
}

savedPath, _, err := dl.DownloadTo(helmRepo, version, chartDir)
savedPath, _, err := dl.DownloadTo(resolvedRepo, version, chartDir)
if err != nil {
return nil, fmt.Errorf("error downloading chart %q version %q: %w", helmRepo, version, err)
}
Expand Down Expand Up @@ -205,3 +214,99 @@ func getVersion(versionOverride, helmRepo string) (string, error) {

return versionOverride, nil
}

// resolveHTTPChartURL resolves the real download URL for an HTTP/S Helm repo reference
// of the form <scheme>://<host>/<repoPath>/<chartName>:<version> — the output of
// (*v1.Helm).ChartReference(). It fetches index.yaml, looks up the chart entry, and
// returns the absolute URL from urls[0].
//
// Returns helmRepo unchanged when no resolution was possible.
func resolveHTTPChartURL(ctx context.Context, helmRepo, requestedVersion, tmpDir string, providers getter.Providers, getterOpts []getter.Option) (string, error) {
// resolveHTTPChartURL is called speculatively: helmRepo may be a direct .tgz URL,
// an OCI reference, or any form not produced by ChartReference(). All of those are
// passed through unchanged so that helm's DownloadTo can handle them directly.
if !strings.HasPrefix(helmRepo, "http://") && !strings.HasPrefix(helmRepo, "https://") {
Comment thread
matthiasbruns marked this conversation as resolved.
return helmRepo, nil
}

ref, err := looseref.ParseReference(helmRepo)
if err != nil {
return helmRepo, nil
}

// Tag holds the version; Repository holds "<host>/<repoPath>/<chartName>".
// If either is absent this isn't a ChartReference()-style URL.
if ref.Tag == "" || ref.Repository == "" {
return helmRepo, nil
}

// chartName is the last path segment of the repository, repoPath is everything before it.
chartName := path.Base(ref.Repository)
repoPath := path.Dir(ref.Repository)
base := &url.URL{
Scheme: ref.Scheme,
Host: ref.Registry,
}
if repoPath != "." {
base.Path = "/" + repoPath
}
repoBase := base.String()
chartVersion := ref.Tag
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if requestedVersion != "" {
chartVersion = requestedVersion
}

indexURL, err := helmrepo.ResolveReferenceURL(repoBase, "index.yaml")
if err != nil {
return "", fmt.Errorf("error constructing index.yaml URL for %q: %w", repoBase, err)
}

scheme := ref.Scheme
g, err := providers.ByScheme(scheme)
if err != nil {
return "", fmt.Errorf("no getter for scheme %q: %w", scheme, err)
}

slog.DebugContext(ctx, "fetching Helm repository index", "url", indexURL)

buf, err := g.Get(indexURL, getterOpts...)
if err != nil {
return "", fmt.Errorf("error fetching index.yaml from %q: %w", indexURL, err)
}

tmpFile, err := os.CreateTemp(tmpDir, "helm-index-*.yaml")
if err != nil {
return "", fmt.Errorf("error creating temp file for index.yaml: %w", err)
}
tmpPath := tmpFile.Name()
defer func() { _ = os.Remove(tmpPath) }()

if _, err = io.Copy(tmpFile, buf); err != nil {
_ = tmpFile.Close()
return "", fmt.Errorf("error writing index.yaml to temp file: %w", err)
}
if err = tmpFile.Close(); err != nil {
return "", fmt.Errorf("error closing index.yaml temp file: %w", err)
}

index, err := helmrepo.LoadIndexFile(tmpPath)
if err != nil {
return "", fmt.Errorf("error parsing index.yaml from %q: %w", repoBase, err)
}

cv, err := index.Get(chartName, chartVersion)
if err != nil {
return "", fmt.Errorf("chart %q version %q not found in index at %q: %w", chartName, chartVersion, repoBase, err)
}

if len(cv.URLs) == 0 {
return "", fmt.Errorf("chart %q version %q has no download URLs in index at %q", chartName, chartVersion, repoBase)
}

absURL, err := helmrepo.ResolveReferenceURL(repoBase, cv.URLs[0])
if err != nil {
return "", fmt.Errorf("error resolving chart URL %q against base %q: %w", cv.URLs[0], repoBase, err)
}

return absURL, nil
}
236 changes: 236 additions & 0 deletions bindings/go/helm/internal/download/download_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package download

import (
"fmt"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -278,6 +279,241 @@ func TestNewReadOnlyChartFromRemote_BasicAuthAccessTokenFallback(t *testing.T) {
}
}

// TestNewReadOnlyChartFromRemote_HTTPRepoResolvesViaIndexYAML verifies that when
// NewReadOnlyChartFromRemote receives an HTTP repo reference of the form
// <repo>/<chartName>:<version> (the output of (*v1.Helm).ChartReference()), it
// fetches index.yaml, resolves the canonical chart URL from the index, and
// downloads the chart from that URL.
//
// Before the fix this test fails because Helm v4's DownloadTo GETs the
// constructed URL directly without consulting index.yaml, returning 404.
func TestNewReadOnlyChartFromRemote_HTTPRepoResolvesViaIndexYAML(t *testing.T) {
r := require.New(t)

workDir, err := os.Getwd()
r.NoError(err)
testDataDir := filepath.Join(workDir, "..", "..", "testdata")

// The server URL is not known until after httptest.NewServer returns, so we
// capture it via a pointer that is populated before any request arrives.
var srvURL string

mux := http.NewServeMux()

// /mychart/mychart-0.1.0.tgz — the canonical archive URL advertised in index.yaml
mux.HandleFunc("/mychart/mychart-0.1.0.tgz", func(w http.ResponseWriter, req *http.Request) {
http.ServeFile(w, req, filepath.Join(testDataDir, "mychart-0.1.0.tgz"))
})

// /index.yaml — dynamically built so the urls field contains the real server address
mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, req *http.Request) {
index := "apiVersion: v1\n" +
"generated: \"2024-01-01T00:00:00.000Z\"\n" +
"entries:\n" +
" mychart:\n" +
" - name: mychart\n" +
" version: 0.1.0\n" +
" apiVersion: v2\n" +
" urls:\n" +
" - " + srvURL + "/mychart/mychart-0.1.0.tgz\n"
w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write([]byte(index))
})

// Anything else (including the constructed <repo>/mychart:0.1.0) → 404
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
http.NotFound(w, req)
})

srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
srvURL = srv.URL

// This is exactly what (*v1.Helm).ChartReference() produces for an HTTP repo:
// url.JoinPath(repoURL, chartName) + ":" + version
helmRepoRef := srv.URL + "/mychart:0.1.0"

chart, err := NewReadOnlyChartFromRemote(t.Context(), helmRepoRef, t.TempDir())
r.NoError(err)
r.NotNil(chart)
assert.Equal(t, "mychart", chart.Name)
assert.Equal(t, "0.1.0", chart.Version)
}

// TestNewReadOnlyChartFromRemote_VersionOverrideTakesPrecedenceInIndexLookup verifies
// that when opt.Version is set, resolveHTTPChartURL uses it for the index.yaml lookup
// rather than the tag embedded in the repo URL. Without the fix, the index lookup would
// use the URL tag (which doesn't exist in the index) and return an error.
func TestNewReadOnlyChartFromRemote_VersionOverrideTakesPrecedenceInIndexLookup(t *testing.T) {
r := require.New(t)

workDir, err := os.Getwd()
r.NoError(err)
testDataDir := filepath.Join(workDir, "..", "..", "testdata")

var srvURL string

mux := http.NewServeMux()

mux.HandleFunc("/mychart/mychart-0.1.0.tgz", func(w http.ResponseWriter, req *http.Request) {
http.ServeFile(w, req, filepath.Join(testDataDir, "mychart-0.1.0.tgz"))
})

mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, req *http.Request) {
index := "apiVersion: v1\n" +
"generated: \"2024-01-01T00:00:00.000Z\"\n" +
"entries:\n" +
" mychart:\n" +
" - name: mychart\n" +
" version: 0.1.0\n" +
" apiVersion: v2\n" +
" urls:\n" +
" - " + srvURL + "/mychart/mychart-0.1.0.tgz\n"
w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write([]byte(index))
})

mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
http.NotFound(w, req)
})

srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
srvURL = srv.URL

// URL tag is "wrong-tag" — only 0.1.0 exists in the index.
// opt.Version = "0.1.0" must win so the index lookup succeeds.
helmRepoRef := srv.URL + "/mychart:wrong-tag"

chart, err := NewReadOnlyChartFromRemote(t.Context(), helmRepoRef, t.TempDir(), WithVersion("0.1.0"))
r.NoError(err)
r.NotNil(chart)
assert.Equal(t, "mychart", chart.Name)
assert.Equal(t, "0.1.0", chart.Version)
}

func TestResolveHTTPChartURL(t *testing.T) {
makeIndex := func(srvURL string) string {
return "apiVersion: v1\n" +
"generated: \"2024-01-01T00:00:00.000Z\"\n" +
"entries:\n" +
" mychart:\n" +
" - name: mychart\n" +
" version: 0.1.0\n" +
" apiVersion: v2\n" +
" urls:\n" +
" - " + srvURL + "/mychart/mychart-0.1.0.tgz\n"
}

tests := []struct {
name string
helmRepo string // %s is replaced with srv.URL when setupMux != nil
requestedVersion string
setupMux func(mux *http.ServeMux, getSrvURL func() string)
wantSuffix string // expected result = srv.URL + wantSuffix (server cases only)
wantErr string
}{
{
name: "OCI URL passes through unchanged",
helmRepo: "oci://registry.example.com/charts/mychart:1.0.0",
},
{
name: "direct tgz URL without version tag passes through",
helmRepo: "https://example.com/charts/mychart-1.0.0.tgz",
},
{
name: "HTTP/S URL without tag passes through",
helmRepo: "https://example.com/mychart",
},
{
name: "resolves chart URL from index.yaml",
helmRepo: "%s/mychart:0.1.0",
setupMux: func(mux *http.ServeMux, getSrvURL func() string) {
mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write([]byte(makeIndex(getSrvURL())))
})
},
wantSuffix: "/mychart/mychart-0.1.0.tgz",
},
{
name: "requestedVersion overrides URL tag in index lookup",
helmRepo: "%s/mychart:wrong-tag",
requestedVersion: "0.1.0",
setupMux: func(mux *http.ServeMux, getSrvURL func() string) {
mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write([]byte(makeIndex(getSrvURL())))
})
},
wantSuffix: "/mychart/mychart-0.1.0.tgz",
},
{
name: "chart not found in index returns error",
helmRepo: "%s/notexist:9.9.9",
setupMux: func(mux *http.ServeMux, getSrvURL func() string) {
mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write([]byte(makeIndex(getSrvURL())))
})
},
wantErr: "not found in index",
},
{
name: "index fetch failure returns error",
helmRepo: "%s/mychart:0.1.0",
setupMux: func(mux *http.ServeMux, _ func() string) {
mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
})
},
wantErr: "error fetching index.yaml",
},
{
name: "chart entry with no URLs returns error",
helmRepo: "%s/mychart:0.1.0",
setupMux: func(mux *http.ServeMux, _ func() string) {
mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write([]byte("apiVersion: v1\ngenerated: \"2024-01-01T00:00:00.000Z\"\nentries:\n mychart:\n - name: mychart\n version: 0.1.0\n apiVersion: v2\n urls: []\n"))
})
},
wantErr: "no download URLs",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
helmRepo := tt.helmRepo
wantResult := helmRepo

if tt.setupMux != nil {
var srvURL string
mux := http.NewServeMux()
tt.setupMux(mux, func() string { return srvURL })
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
srvURL = srv.URL

helmRepo = fmt.Sprintf(helmRepo, srvURL)
if tt.wantErr == "" {
wantResult = srvURL + tt.wantSuffix
}
}

result, err := resolveHTTPChartURL(t.Context(), helmRepo, tt.requestedVersion, t.TempDir(), GetterProviders(), nil)

if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, wantResult, result)
})
}
}

// newBasicAuthChartServer returns an httptest server that serves files from dir
// only when the request carries matching HTTP Basic Auth credentials.
func newBasicAuthChartServer(t *testing.T, dir, user, pass string) *httptest.Server {
Expand Down
Loading