diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index a61534a4..6832dd33 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -125,6 +125,7 @@ import ( "github.com/git-pkgs/proxy/internal/config" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/denylist" + "github.com/git-pkgs/proxy/internal/handler" "github.com/git-pkgs/proxy/internal/memlimit" "github.com/git-pkgs/proxy/internal/mirror" "github.com/git-pkgs/proxy/internal/server" @@ -558,18 +559,13 @@ func runMirror() { proxy.MetadataTTL = cfg.ParseMetadataTTL() proxy.MetadataMaxSize = cfg.ParseMetadataMaxSize() - m := mirror.New(proxy, db, store, logger, *concurrency) - m.Images = images - m.Versions = server.NewMirrorVersions(cfg.Upstream, proxy.HTTPClient) + m := newCommandMirror(cfg, proxy, images, db, store, logger, *concurrency) switch s := source.(type) { case *mirror.PURLSource: s.Versions = m.Versions case *mirror.SBOMSource: s.Versions = m.Versions } - if cfg.Scanning.Enabled { - refuseUnscannedMirroring(m) - } ctx, cancel := context.WithCancel(context.Background()) go func() { @@ -616,6 +612,20 @@ func runMirror() { } } +// newCommandMirror builds the mirror proxy mirror runs, which fetches and +// lists versions from the configured upstreams. +func newCommandMirror(cfg *config.Config, proxy *handler.Proxy, images *handler.ContainerHandler, + db *database.DB, store storage.Storage, logger *slog.Logger, workers int) *mirror.Mirror { + m := mirror.New(proxy, db, store, logger, workers) + m.Images = images + m.Charts = handler.NewHelmHandler(proxy, cfg.BaseURL, cfg.Upstream.Helm) + m.Versions = server.NewMirrorVersions(cfg.Upstream, proxy.HTTPClient) + if cfg.Scanning.Enabled { + refuseUnscannedMirroring(m) + } + return m +} + // mirrorExitCode returns the exit status for a finished mirror run: 1 when any // package version failed to mirror, 0 otherwise. func mirrorExitCode(progress *mirror.Progress) int { diff --git a/docs/configuration.md b/docs/configuration.md index 269d5bdb..a18a4646 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1565,6 +1565,8 @@ A conda version is mirrored with every build anaconda.org lists for it, across a A Composer release is mirrored with the dist archive `upstream.composer` lists for it, checked against its shasum when one is listed, and cached under the name `/composer/files/...` serves it as. Give the version as the repository lists it, for example `pkg:composer/monolog/monolog@3.8.1`. Branches such as `dev-main` aren't mirrored, since their archive changes with every push, and neither are packages in `upstream.composer_routes`. +A Helm chart is mirrored from a repository in `upstream.helm`, named as the namespace, for example `pkg:helm/prometheus-community/prometheus@27.1.0` with `prometheus-community` configured. The chart is fetched from the first URL the repository's `index.yaml` lists for that version, checked against the listed digest, and cached under the repository and digest `/helm/{name}/charts/...` serves it from. The index is cached as well, so with [`cache_metadata`](#metadata-caching) on `helm pull` works while the repository is down. Without a version every version in the index is mirrored. Charts named without a repository, or from one that isn't configured, are refused, and so are charts the index lists in an OCI registry: helm pulls those through `/v2`, so mirror them as `pkg:oci` images. + | Flag | Default | Description | |------|---------|-------------| | `--sbom` | | Path to CycloneDX or SPDX SBOM file | diff --git a/internal/handler/helm.go b/internal/handler/helm.go index b8000698..07e2f74e 100644 --- a/internal/handler/helm.go +++ b/internal/handler/helm.go @@ -1,6 +1,7 @@ package handler import ( + "context" "crypto/sha256" "encoding/hex" "errors" @@ -82,7 +83,7 @@ func (h *HelmHandler) handleIndex(w http.ResponseWriter, r *http.Request) { return } - index := h.fetchIndex(r, repository, upstreamURL) + index := h.fetchIndex(r.Context(), repository, upstreamURL) if index.err != nil { h.serveIndexError(w, index.err) return @@ -130,7 +131,7 @@ func (h *HelmHandler) handleChart(w http.ResponseWriter, r *http.Request) { return } - index := h.fetchIndex(r, repository, upstreamURL) + index := h.fetchIndex(r.Context(), repository, upstreamURL) if index.err != nil { h.serveIndexError(w, index.err) return @@ -182,9 +183,9 @@ func (h *HelmHandler) repositoryForRequest(r *http.Request) (name, upstreamURL s return name, upstreamURL, ok } -func (h *HelmHandler) fetchIndex(r *http.Request, repository, upstreamURL string) metadataResult { +func (h *HelmHandler) fetchIndex(ctx context.Context, repository, upstreamURL string) metadataResult { return h.proxy.fetchMetadataDocument( - r.Context(), + ctx, helmMetadataEcosystem, h.indexCacheKey(repository, upstreamURL), upstreamURL+"/"+helmIndexFilename, @@ -278,10 +279,11 @@ func (h *HelmHandler) findChartDownload(repository, upstreamURL string, index me } // helmChartLookup holds the HTTP chart downloads an index lists, by chart -// digest, in index order. +// digest, in index order, and each release by chart name and version. type helmChartLookup struct { indexDigest string byDigest map[string][]helmChartEntry + byRelease map[helmRelease]helmReleaseDownload } type helmChartEntry struct { @@ -290,6 +292,16 @@ type helmChartEntry struct { downloads []helmChartDownload } +type helmRelease struct{ chart, version string } + +// helmReleaseDownload is what helm downloads for a release: the first URL +// the index lists for it, checked against its digest. +type helmReleaseDownload struct { + digest string + created time.Time + first helmChartDownload +} + // chartLookup returns the charts the index lists, parsing it only when the // last lookup built for the repository came from other bytes. An index // without a digest, read with the metadata cache off, is parsed every time. @@ -326,7 +338,7 @@ func (h *HelmHandler) buildChartLookup(upstreamURL string, body []byte) (*helmCh return nil, err } - lookup := &helmChartLookup{byDigest: make(map[string][]helmChartEntry)} + lookup := &helmChartLookup{byDigest: make(map[string][]helmChartEntry), byRelease: make(map[helmRelease]helmReleaseDownload)} for i := 0; i < len(entries.Content); i += 2 { chartName := entries.Content[i].Value releases := entries.Content[i+1] @@ -339,6 +351,18 @@ func (h *HelmHandler) buildChartLookup(upstreamURL string, body []byte) (*helmCh continue } entry := helmChartEntry{name: chartName, created: chart.created} + if version := helmMappingValue(release, "version"); version != nil && version.Value != "" { + // helm takes the first release listed for a version. + key := helmRelease{chartName, version.Value} + if _, listed := lookup.byRelease[key]; !listed { + first := chart.downloads[0] + lookup.byRelease[key] = helmReleaseDownload{ + digest: chart.digest, + created: chart.created, + first: helmChartDownload{url: first.url, filename: first.filename, oci: first.oci}, + } + } + } for _, download := range chart.downloads { if !download.oci { // Drop the YAML node, so the lookup doesn't keep the diff --git a/internal/handler/helm_mirror.go b/internal/handler/helm_mirror.go new file mode 100644 index 00000000..fe2199dd --- /dev/null +++ b/internal/handler/helm_mirror.go @@ -0,0 +1,67 @@ +package handler + +import ( + "context" + "fmt" + "path" + "slices" + "strings" +) + +// MirrorChart caches a chart release of a configured repository under the +// key handleChart serves it from, checked against the digest the index +// lists. The index is read through the metadata cache, as handleIndex reads +// it, so with metadata caching on helm finds the chart there as well while +// the repository is down. +func (h *HelmHandler) MirrorChart(ctx context.Context, repository, chart, version string) (*CacheResult, error) { + upstreamURL, ok := h.repositories[repository] + if !ok { + return nil, h.unknownRepositoryError(repository) + } + index := h.fetchIndex(ctx, repository, upstreamURL) + if index.err != nil { + return nil, fmt.Errorf("fetching the %s index: %w", repository, index.err) + } + charts, err := h.chartLookup(repository, upstreamURL, index) + if err != nil { + return nil, err + } + + release, ok := charts.byRelease[helmRelease{chart, version}] + switch { + case !ok: + return nil, fmt.Errorf("%w: %s %s is not in the %s index", ErrUpstreamNotFound, chart, version, repository) + case release.first.oci: + return nil, fmt.Errorf("%s %s is published as %s, which helm pulls through the container registry routes; mirror it as an image: %s", + chart, version, release.first.url, ociChartPURL(release.first.url, version)) + case h.chartOnCooldown(chart, release.created): + return nil, fmt.Errorf("%s %s is in cooldown, so the index doesn't list it", chart, version) + } + return h.proxy.GetOrFetchArtifactFromURLWithDigest(ctx, helmMetadataEcosystem, repository, release.digest, + release.first.filename, release.first.url, "sha256:"+release.digest) +} + +func (h *HelmHandler) unknownRepositoryError(repository string) error { + names := make([]string, 0, len(h.repositories)) + for name := range h.repositories { + names = append(names, name) + } + if len(names) == 0 { + return fmt.Errorf("unknown Helm repository %q: upstream.helm configures none", repository) + } + slices.Sort(names) + return fmt.Errorf("unknown Helm repository %q: upstream.helm configures %s", repository, strings.Join(names, ", ")) +} + +// ociChartPURL returns the image PURL that mirrors an OCI chart reference, +// tagged with the chart version when the reference has no tag or digest, as +// helm pulls it. +func ociChartPURL(reference, version string) string { + repository := strings.TrimPrefix(reference, "oci://") + if at := strings.LastIndex(repository, "@"); at >= 0 { + repository, version = repository[:at], repository[at+1:] + } else if colon := strings.LastIndex(repository, ":"); colon > strings.LastIndex(repository, "/") { + repository, version = repository[:colon], repository[colon+1:] + } + return "pkg:oci/" + path.Base(repository) + "@" + version + "?repository_url=" + repository +} diff --git a/internal/handler/helm_mirror_test.go b/internal/handler/helm_mirror_test.go new file mode 100644 index 00000000..617d35df --- /dev/null +++ b/internal/handler/helm_mirror_test.go @@ -0,0 +1,17 @@ +package handler + +import "testing" + +func TestOCIChartPURL(t *testing.T) { + tests := []struct{ reference, want string }{ + {"oci://ghcr.io/owner/demo:2.0.0", "pkg:oci/demo@2.0.0?repository_url=ghcr.io/owner/demo"}, + {"oci://ghcr.io/owner/demo", "pkg:oci/demo@1.0.0?repository_url=ghcr.io/owner/demo"}, + {"oci://localhost:5000/demo", "pkg:oci/demo@1.0.0?repository_url=localhost:5000/demo"}, + {"oci://ghcr.io/owner/demo@sha256:abc", "pkg:oci/demo@sha256:abc?repository_url=ghcr.io/owner/demo"}, + } + for _, tt := range tests { + if got := ociChartPURL(tt.reference, "1.0.0"); got != tt.want { + t.Errorf("ociChartPURL(%q) = %q, want %q", tt.reference, got, tt.want) + } + } +} diff --git a/internal/handler/helm_pypi_rewrite_test.go b/internal/handler/helm_pypi_rewrite_test.go index 8a8dd676..3500f8ec 100644 --- a/internal/handler/helm_pypi_rewrite_test.go +++ b/internal/handler/helm_pypi_rewrite_test.go @@ -1,6 +1,7 @@ package handler import ( + "context" "errors" "fmt" "net/http" @@ -138,7 +139,7 @@ func TestHelmChartLookupFollowsIndex(t *testing.T) { proxy.MetadataTTL = time.Hour h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"stable": upstream.URL}) index := func() metadataResult { - return h.fetchIndex(httptest.NewRequest(http.MethodGet, "/", nil), "stable", upstream.URL) + return h.fetchIndex(context.Background(), "stable", upstream.URL) } first, err := h.findChartDownload("stable", upstream.URL, index(), digestA, "a-1.0.0.tgz") diff --git a/internal/mirror/helm.go b/internal/mirror/helm.go new file mode 100644 index 00000000..bda16dd4 --- /dev/null +++ b/internal/mirror/helm.go @@ -0,0 +1,40 @@ +package mirror + +import ( + "context" + "errors" + "strings" + + "github.com/git-pkgs/proxy/internal/handler" +) + +// ChartMirrorer caches Helm charts from the configured repositories the way +// the Helm handler serves them. +type ChartMirrorer interface { + MirrorChart(ctx context.Context, repository, chart, version string) (*handler.CacheResult, error) +} + +var ( + errHelmNoRepository = errors.New("helm charts need the upstream.helm repository as the namespace, such as pkg:helm/{repository}/{chart}@{version}") + errHelmUnavailable = errors.New("mirroring Helm charts isn't available here") +) + +// mirrorHelm mirrors a chart release. The PURL namespace names the +// upstream.helm repository, since the handler caches charts per repository. +func (m *Mirror) mirrorHelm(ctx context.Context, pv PackageVersion, tracker *progressTracker) { + repository, chart, ok := strings.Cut(pv.Name, "/") + if !ok || repository == "" || chart == "" { + m.recordFailure(tracker, pv, errHelmNoRepository) + return + } + if m.Charts == nil { + m.recordFailure(tracker, pv, errHelmUnavailable) + return + } + result, err := m.Charts.MirrorChart(ctx, repository, chart, pv.Version) + if err != nil { + m.recordFailure(tracker, pv, err) + return + } + m.recordResult(tracker, pv, result) +} diff --git a/internal/mirror/mirror.go b/internal/mirror/mirror.go index 6bd8d183..0a1142d9 100644 --- a/internal/mirror/mirror.go +++ b/internal/mirror/mirror.go @@ -29,6 +29,9 @@ type Mirror struct { // other unsupported ecosystem. Images ImageMirrorer + // Charts mirrors helm charts. Without it they fail. + Charts ChartMirrorer + // Versions lists the versions of unversioned PURLs in mirror jobs and // schedules. Nil lists them from the public registries. Versions VersionLister @@ -236,6 +239,10 @@ func (m *Mirror) mirrorOne(ctx context.Context, pv PackageVersion, tracker *prog m.mirrorComposer(ctx, pv, tracker) return } + if pv.Ecosystem == "helm" { + m.mirrorHelm(ctx, pv, tracker) + return + } result, err := m.proxy.GetOrFetchArtifact(ctx, pv.Ecosystem, pv.Name, pv.Version, "") if err != nil { m.recordFailure(tracker, pv, err) diff --git a/internal/mirror/versions.go b/internal/mirror/versions.go index 1b68a6dd..64fb36d2 100644 --- a/internal/mirror/versions.go +++ b/internal/mirror/versions.go @@ -3,6 +3,7 @@ package mirror import ( "context" "fmt" + "strings" "github.com/git-pkgs/purl" "github.com/git-pkgs/registries" @@ -73,5 +74,10 @@ func (r RegistryVersions) fetchVersions(ctx context.Context, ecosystem, name str if err != nil { return nil, err } + if purlType == "helm" { + // The namespace names the repository, which URL resolves to the + // index the chart is listed in. + _, name, _ = strings.Cut(name, "/") + } return reg.FetchVersions(ctx, name) } diff --git a/internal/server/mirror_fetch_test.go b/internal/server/mirror_fetch_test.go index 142594c9..86a5d8e0 100644 --- a/internal/server/mirror_fetch_test.go +++ b/internal/server/mirror_fetch_test.go @@ -59,12 +59,14 @@ func mirrorWithVersions(t *testing.T, cfg *config.Config, versions mirror.Versio } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) proxy, _ := NewMirrorProxy(cfg, db, store, logger) + proxy.CacheMetadata = true // as proxy mirror does m := mirror.New(proxy, db, store, logger, 1) + m.Charts = handler.NewHelmHandler(proxy, cfg.BaseURL, cfg.Upstream.Helm) m.Versions = versions if versions == nil { m.Versions = NewMirrorVersions(cfg.Upstream, proxy.HTTPClient) } - progress, err := m.Run(context.Background(), &mirror.PURLSource{PURLs: purls}) + progress, err := m.Run(context.Background(), &mirror.PURLSource{PURLs: purls, Versions: m.Versions}) if err != nil { t.Fatalf("Run() error = %v", err) } diff --git a/internal/server/mirror_helm_test.go b/internal/server/mirror_helm_test.go new file mode 100644 index 00000000..47833e66 --- /dev/null +++ b/internal/server/mirror_helm_test.go @@ -0,0 +1,147 @@ +package server + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/git-pkgs/proxy/internal/config" + "github.com/git-pkgs/proxy/internal/handler" +) + +// helmUpstream serves an HTTP Helm repository listing demo 1.0.0 at a +// relative URL and demo 2.0.0 in an OCI registry, and the 1.0.0 chart. +type helmUpstream struct { + *httptest.Server + chart string + digest string + chartHits atomic.Int32 +} + +func newHelmUpstream(t *testing.T) *helmUpstream { + t.Helper() + u := &helmUpstream{chart: "demo chart archive"} + sum := sha256.Sum256([]byte(u.chart)) + u.digest = hex.EncodeToString(sum[:]) + u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/index.yaml": + _, _ = fmt.Fprintf(w, `apiVersion: v1 +entries: + demo: + - name: demo + version: 2.0.0 + digest: %s + urls: [oci://ghcr.io/owner/demo:2.0.0] + - name: demo + version: 1.0.0 + digest: %s + urls: [charts/demo-1.0.0.tgz] +`, strings.Repeat("b", 64), u.digest) + case "/charts/demo-1.0.0.tgz": + u.chartHits.Add(1) + _, _ = io.WriteString(w, u.chart) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(u.Close) + return u +} + +func helmMirrorConfig(upstream *helmUpstream) *config.Config { + cfg := config.Default() + cfg.Upstream.Helm = map[string]string{"stable": upstream.URL} + return cfg +} + +func TestMirrorHelmChartIsServedFromCache(t *testing.T) { + upstream := newHelmUpstream(t) + cfg := helmMirrorConfig(upstream) + + progress, proxy := mirrorWithConfig(t, cfg, "pkg:helm/stable/demo@1.0.0") + + if progress.Completed != 1 || upstream.chartHits.Load() != 1 { + t.Fatalf("completed = %d, chart hits = %d, errors = %v", progress.Completed, upstream.chartHits.Load(), progress.Errors) + } + + // helm reads the index and then the chart it points to, both from the + // cache once the repository is down. + upstream.Close() + helm := handler.NewHelmHandler(proxy, "http://proxy.test", cfg.Upstream.Helm).Routes() + chartPath := "/stable/charts/" + upstream.digest + "/demo-1.0.0.tgz" + rec := httptest.NewRecorder() + helm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/stable/index.yaml", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "http://proxy.test/helm"+chartPath) { + t.Fatalf("index from cache: status = %d, body %q; want 200 listing %s", rec.Code, rec.Body.String(), chartPath) + } + rec = httptest.NewRecorder() + helm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, chartPath, nil)) + if rec.Code != http.StatusOK || rec.Body.String() != upstream.chart { + t.Errorf("chart from cache: status = %d, body %q; want 200 and the mirrored chart", rec.Code, strings.TrimSpace(rec.Body.String())) + } +} + +func TestMirrorHelmListsVersions(t *testing.T) { + upstream := newHelmUpstream(t) + + progress, _ := mirrorWithConfig(t, helmMirrorConfig(upstream), "pkg:helm/stable/demo") + + // 2.0.0 is in an OCI registry and refused. + if progress.Total != 2 || progress.Completed != 1 || progress.Failed != 1 || upstream.chartHits.Load() != 1 { + t.Errorf("total = %d, completed = %d, failed = %d, chart hits = %d, errors = %v; want both versions listed and 1.0.0 mirrored", + progress.Total, progress.Completed, progress.Failed, upstream.chartHits.Load(), progress.Errors) + } +} + +func TestMirrorHelmRefusesDigestMismatch(t *testing.T) { + upstream := newHelmUpstream(t) + want := upstream.chart + upstream.chart = "tampered chart archive" + cfg := helmMirrorConfig(upstream) + + progress, proxy := mirrorWithConfig(t, cfg, "pkg:helm/stable/demo@1.0.0") + + if progress.Failed != 1 || progress.Completed != 0 { + t.Fatalf("completed = %d, failed = %d, errors = %v; want the chart refused", progress.Completed, progress.Failed, progress.Errors) + } + sum := sha256.Sum256([]byte(want)) + upstream.Close() + rec := httptest.NewRecorder() + handler.NewHelmHandler(proxy, "http://proxy.test", cfg.Upstream.Helm).Routes().ServeHTTP(rec, + httptest.NewRequest(http.MethodGet, "/stable/charts/"+hex.EncodeToString(sum[:])+"/demo-1.0.0.tgz", nil)) + if rec.Code == http.StatusOK { + t.Error("the tampered chart was cached") + } +} + +func TestMirrorHelmRefusals(t *testing.T) { + tests := []struct { + name, purl, want string + }{ + {"OCI chart", "pkg:helm/stable/demo@2.0.0", "pkg:oci/demo@2.0.0?repository_url=ghcr.io/owner/demo"}, + {"unknown repository", "pkg:helm/other/demo@1.0.0", `unknown Helm repository "other": upstream.helm configures stable`}, + {"no repository", "pkg:helm/demo@1.0.0", "pkg:helm/{repository}/{chart}@{version}"}, + {"unknown version", "pkg:helm/stable/demo@3.0.0", "not in the stable index"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := newHelmUpstream(t) + + progress, _ := mirrorWithConfig(t, helmMirrorConfig(upstream), tt.purl) + + if progress.Failed != 1 || len(progress.Errors) != 1 || !strings.Contains(progress.Errors[0].Error, tt.want) { + t.Errorf("failed = %d, errors = %v; want an error mentioning %q", progress.Failed, progress.Errors, tt.want) + } + if upstream.chartHits.Load() != 0 { + t.Error("a chart was fetched") + } + }) + } +} diff --git a/internal/server/mirror_versions.go b/internal/server/mirror_versions.go index 404388d7..e093a985 100644 --- a/internal/server/mirror_versions.go +++ b/internal/server/mirror_versions.go @@ -30,6 +30,7 @@ func NewMirrorVersions(upstream config.UpstreamConfig, client *http.Client) mirr func (s *Server) newMirror(proxy *handler.Proxy, workers int) *mirror.Mirror { m := mirror.New(proxy, s.db, s.storage, s.logger, workers) m.Images = s.newContainerHandler(proxy) + m.Charts = handler.NewHelmHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Helm) m.Versions = NewMirrorVersions(s.cfg.Upstream, proxy.HTTPClient) return m } @@ -80,6 +81,17 @@ func mirrorVersionsURL(u config.UpstreamConfig, purlType, name string) (string, return "", nil case "julia": return publicOnly("upstream.julia", u.Julia, d.Julia) + case "helm": + // The namespace names the repository; see RegistryVersions. + repository, _, ok := strings.Cut(name, "/") + if !ok { + return "", errors.New("helm charts need the upstream.helm repository as the namespace, such as pkg:helm/{repository}/{chart}") + } + repositoryURL, ok := u.Helm[repository] + if !ok { + return "", fmt.Errorf("unknown Helm repository %q: it isn't in upstream.helm", repository) + } + return strings.TrimSuffix(repositoryURL, "/"), nil default: return "", fmt.Errorf("listing %s versions isn't supported; give a version", purlType) } diff --git a/internal/server/mirror_versions_test.go b/internal/server/mirror_versions_test.go index 898c2128..89fbea32 100644 --- a/internal/server/mirror_versions_test.go +++ b/internal/server/mirror_versions_test.go @@ -96,6 +96,7 @@ func TestMirrorVersionsURL(t *testing.T) { custom.ComposerRoutes = map[string]string{"acme/*": "https://composer.example.com"} custom.CRAN = "https://cran.example.com" custom.HomebrewAPI = "https://brew.example.com/api" + custom.Helm = map[string]string{"stable": "https://charts.example.com/stable/"} custom.Cargo = "https://index.example.com" custom.Conda = "https://conda.example.com" custom.Julia = "https://julia.example.com" @@ -128,7 +129,9 @@ func TestMirrorVersionsURL(t *testing.T) { {"conda channel", custom, "conda", "numpy", "", "upstream.conda"}, {"julia default", config.Default().Upstream, "julia", "JSON", "", ""}, {"julia server", custom, "julia", "JSON", "", "upstream.julia"}, - {"helm", custom, "helm", "nginx", "", "give a version"}, + {"helm", custom, "helm", "stable/nginx", "https://charts.example.com/stable", ""}, + {"helm without repository", custom, "helm", "nginx", "", "namespace"}, + {"helm unknown repository", custom, "helm", "other/nginx", "", "upstream.helm"}, {"swift", custom, "swift", "apple/swift-nio", "", "give a version"}, {"apk", custom, "apk", "curl", "", "give a version"}, }