From 17e4fc763f24d3f643b4f8b541c5eedba1ae933d Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Sun, 25 Oct 2026 19:37:22 +0100 Subject: [PATCH] Mirror Helm charts Mirroring pkg:helm PURLs failed with an unsupported ecosystem error. The namespace now names the upstream.helm repository, as the Helm handler caches charts per repository: pkg:helm/{repository}/{chart}@{version}. The chart is found in the repository's index through the handler's own index lookup, fetched from the first URL the release lists, as helm does, checked against the listed digest and cached under the repository, digest and filename the handler serves it from. The index goes through the metadata cache, so helm finds the chart there while the repository is down. Unversioned PURLs list every version in the index. Charts without a repository, from an unconfigured one, or listed in an OCI registry are refused; the last are pulled through /v2, and the error names the pkg:oci PURL that mirrors them. --- cmd/proxy/main.go | 22 ++- docs/configuration.md | 2 + internal/handler/helm.go | 36 ++++- internal/handler/helm_mirror.go | 67 ++++++++++ internal/handler/helm_mirror_test.go | 17 +++ internal/handler/helm_pypi_rewrite_test.go | 3 +- internal/mirror/helm.go | 40 ++++++ internal/mirror/mirror.go | 7 + internal/mirror/versions.go | 6 + internal/server/mirror_fetch_test.go | 4 +- internal/server/mirror_helm_test.go | 147 +++++++++++++++++++++ internal/server/mirror_versions.go | 12 ++ internal/server/mirror_versions_test.go | 5 +- 13 files changed, 353 insertions(+), 15 deletions(-) create mode 100644 internal/handler/helm_mirror.go create mode 100644 internal/handler/helm_mirror_test.go create mode 100644 internal/mirror/helm.go create mode 100644 internal/server/mirror_helm_test.go 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 1049f2b4..ba974468 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1530,6 +1530,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"}, }