2026-08-16 22:42:55 +05:30
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
"slices"
|
2026-08-16 22:42:55 +05:30
|
|
|
"strings"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/cooldown"
|
|
|
|
|
upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/storage"
|
|
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestHelmHandler_RewritesIndexAndCachesChart(t *testing.T) {
|
|
|
|
|
chart := []byte("a Helm chart")
|
|
|
|
|
digest := helmSHA256Hex(chart)
|
|
|
|
|
var available atomic.Bool
|
|
|
|
|
available.Store(true)
|
|
|
|
|
var indexRequests atomic.Int32
|
|
|
|
|
var chartRequests atomic.Int32
|
|
|
|
|
|
|
|
|
|
var upstream *httptest.Server
|
|
|
|
|
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !available.Load() {
|
|
|
|
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
case "/charts/index.yaml":
|
|
|
|
|
indexRequests.Add(1)
|
|
|
|
|
w.Header().Set("Content-Type", "application/x-yaml")
|
|
|
|
|
_, _ = fmt.Fprintf(w, `apiVersion: v1
|
|
|
|
|
entries:
|
|
|
|
|
demo:
|
|
|
|
|
- annotations:
|
|
|
|
|
example.com/retained: "true"
|
|
|
|
|
created: 2020-01-02T03:04:05Z
|
|
|
|
|
digest: %s
|
|
|
|
|
name: demo
|
|
|
|
|
urls:
|
|
|
|
|
- demo-1.0.0.tgz
|
|
|
|
|
- %s/charts/mirror/demo-1.0.0.tgz
|
|
|
|
|
version: 1.0.0
|
|
|
|
|
generated: 2020-01-02T03:04:05Z
|
|
|
|
|
`, digest, upstream.URL)
|
|
|
|
|
case "/charts/demo-1.0.0.tgz", "/charts/mirror/demo-1.0.0.tgz":
|
|
|
|
|
chartRequests.Add(1)
|
|
|
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
|
|
|
_, _ = w.Write(chart)
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.CacheMetadata = true
|
|
|
|
|
proxy.MetadataTTL = time.Hour
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
|
|
|
|
|
h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"stable": upstream.URL + "/charts"})
|
|
|
|
|
|
|
|
|
|
indexResponse := serveHelmRequest(h, "/stable/index.yaml")
|
|
|
|
|
if indexResponse.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("index status = %d, want 200: %s", indexResponse.Code, indexResponse.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := indexResponse.Header().Get("Content-Type"); got != "application/x-yaml" {
|
|
|
|
|
t.Errorf("index Content-Type = %q, want application/x-yaml", got)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(indexResponse.Body.String(), upstream.URL) {
|
|
|
|
|
t.Errorf("rewritten index contains upstream URL: %s", indexResponse.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(indexResponse.Body.String(), "example.com/retained") {
|
|
|
|
|
t.Errorf("rewritten index lost an unrelated field: %s", indexResponse.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var index map[string]any
|
|
|
|
|
if err := yaml.Unmarshal(indexResponse.Body.Bytes(), &index); err != nil {
|
|
|
|
|
t.Fatalf("parse rewritten index: %v", err)
|
|
|
|
|
}
|
|
|
|
|
entries := index["entries"].(map[string]any)
|
|
|
|
|
release := entries["demo"].([]any)[0].(map[string]any)
|
|
|
|
|
urls := release["urls"].([]any)
|
|
|
|
|
wantURL := "http://proxy.example/helm/stable/charts/" + digest + "/demo-1.0.0.tgz"
|
|
|
|
|
for _, rawURL := range urls {
|
|
|
|
|
if rawURL != wantURL {
|
|
|
|
|
t.Errorf("rewritten URL = %q, want %q", rawURL, wantURL)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
firstChart := serveHelmRequest(h, "/stable/charts/"+digest+"/demo-1.0.0.tgz")
|
|
|
|
|
if firstChart.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("chart status = %d, want 200: %s", firstChart.Code, firstChart.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := firstChart.Body.String(); got != string(chart) {
|
|
|
|
|
t.Errorf("chart body = %q, want %q", got, chart)
|
|
|
|
|
}
|
|
|
|
|
if got := firstChart.Header().Get("Content-Type"); got != "application/gzip" {
|
|
|
|
|
t.Errorf("chart Content-Type = %q, want application/gzip", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Artifact cache availability must not depend on metadata caching or a
|
|
|
|
|
// reachable index upstream.
|
|
|
|
|
proxy.CacheMetadata = false
|
|
|
|
|
available.Store(false)
|
|
|
|
|
cachedChart := serveHelmRequest(h, "/stable/charts/"+digest+"/demo-1.0.0.tgz")
|
|
|
|
|
if cachedChart.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("cached chart status = %d, want 200: %s", cachedChart.Code, cachedChart.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := cachedChart.Body.String(); got != string(chart) {
|
|
|
|
|
t.Errorf("cached chart body = %q, want %q", got, chart)
|
|
|
|
|
}
|
|
|
|
|
if got := indexRequests.Load(); got != 1 {
|
|
|
|
|
t.Errorf("index requests = %d, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
if got := chartRequests.Load(); got != 1 {
|
|
|
|
|
t.Errorf("chart requests = %d, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHelmHandler_RejectsChartDigestMismatch(t *testing.T) {
|
|
|
|
|
chart := []byte("tampered chart")
|
|
|
|
|
digest := helmSHA256Hex([]byte("expected chart"))
|
|
|
|
|
requests := 0
|
|
|
|
|
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
case "/index.yaml":
|
|
|
|
|
_, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n demo:\n - digest: %s\n urls: [demo.tgz]\n", digest)
|
|
|
|
|
case "/demo.tgz":
|
|
|
|
|
requests++
|
|
|
|
|
_, _ = w.Write(chart)
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, store, _ := setupTestProxy(t)
|
|
|
|
|
proxy.CacheMetadata = true
|
|
|
|
|
proxy.MetadataTTL = time.Hour
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"test": upstream.URL})
|
|
|
|
|
|
|
|
|
|
for range 2 {
|
|
|
|
|
response := serveHelmRequest(h, "/test/charts/"+digest+"/demo.tgz")
|
|
|
|
|
if response.Code != http.StatusBadGateway {
|
|
|
|
|
t.Errorf("status = %d, want 502: %s", response.Code, response.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if requests != 2 {
|
|
|
|
|
t.Errorf("chart requests = %d, want 2 after invalid cache entry is cleared", requests)
|
|
|
|
|
}
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
queued, err := proxy.DB.GetDuePendingDeletes(time.Now().Add(time.Hour), 10)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("listing queued deletes: %v", err)
|
|
|
|
|
}
|
|
|
|
|
chartDir := storage.ArtifactPath(helmMetadataEcosystem, "", "test", digest, "")
|
|
|
|
|
stored := 0
|
|
|
|
|
for path := range store.files {
|
|
|
|
|
if !strings.HasPrefix(path, chartDir) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
stored++
|
|
|
|
|
if !slices.Contains(queued, path) {
|
|
|
|
|
t.Errorf("rejected chart at %q is not queued for deletion", path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if stored != requests {
|
|
|
|
|
t.Errorf("found %d stored charts, want one per request (%d)", stored, requests)
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHelmHandler_IndexCacheChangesWithUpstreamURL(t *testing.T) {
|
|
|
|
|
firstDigest := strings.Repeat("a", sha256HexLength)
|
|
|
|
|
secondDigest := strings.Repeat("b", sha256HexLength)
|
|
|
|
|
firstRequests := 0
|
|
|
|
|
secondRequests := 0
|
|
|
|
|
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
firstRequests++
|
|
|
|
|
_, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n demo:\n - digest: %s\n urls: [demo.tgz]\n", firstDigest)
|
|
|
|
|
}))
|
|
|
|
|
defer first.Close()
|
|
|
|
|
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
secondRequests++
|
|
|
|
|
_, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n demo:\n - digest: %s\n urls: [demo.tgz]\n", secondDigest)
|
|
|
|
|
}))
|
|
|
|
|
defer second.Close()
|
|
|
|
|
|
|
|
|
|
proxy, db, store, _ := setupTestProxy(t)
|
|
|
|
|
proxy.CacheMetadata = true
|
|
|
|
|
proxy.MetadataTTL = time.Hour
|
|
|
|
|
proxy.HTTPClient = first.Client()
|
|
|
|
|
firstHandler := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"stable": first.URL})
|
|
|
|
|
if response := serveHelmRequest(firstHandler, "/stable/index.yaml"); response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("first index status = %d, want 200: %s", response.Code, response.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Model a restarted server with the same database and storage but a changed
|
|
|
|
|
// repository URL. Its cache key must not reuse the previous index or ETag.
|
|
|
|
|
restartedProxy := NewProxy(db, store, &mockFetcher{}, fetch.NewResolver(), nil)
|
|
|
|
|
restartedProxy.CacheMetadata = true
|
|
|
|
|
restartedProxy.MetadataTTL = time.Hour
|
|
|
|
|
restartedProxy.HTTPClient = second.Client()
|
|
|
|
|
secondHandler := NewHelmHandler(restartedProxy, "http://proxy.example", map[string]string{"stable": second.URL})
|
|
|
|
|
response := serveHelmRequest(secondHandler, "/stable/index.yaml")
|
|
|
|
|
if response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("second index status = %d, want 200: %s", response.Code, response.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(response.Body.String(), secondDigest) {
|
|
|
|
|
t.Errorf("second index did not use the new upstream: %s", response.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if firstRequests != 1 {
|
|
|
|
|
t.Errorf("first upstream requests = %d, want 1", firstRequests)
|
|
|
|
|
}
|
|
|
|
|
if secondRequests != 1 {
|
|
|
|
|
t.Errorf("second upstream requests = %d, want 1", secondRequests)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHelmHandler_UsesConfiguredUpstreamAuthentication(t *testing.T) {
|
|
|
|
|
chart := []byte("private Helm chart")
|
|
|
|
|
digest := helmSHA256Hex(chart)
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Header.Get("Authorization") != "Bearer private-token" {
|
|
|
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
case "/index.yaml":
|
|
|
|
|
_, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n demo:\n - digest: %s\n urls: [demo.tgz]\n", digest)
|
|
|
|
|
case "/demo.tgz":
|
|
|
|
|
_, _ = w.Write(chart)
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.CacheMetadata = true
|
|
|
|
|
proxy.MetadataTTL = time.Hour
|
|
|
|
|
authClient := &http.Client{Transport: upstreamhttp.NewTransport(http.DefaultTransport,
|
|
|
|
|
upstreamhttp.AuthFunc(func(string) (string, string) {
|
|
|
|
|
return "Authorization", "Bearer private-token"
|
|
|
|
|
}))}
|
|
|
|
|
proxy.HTTPClient = authClient
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(authClient), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"private": upstream.URL})
|
|
|
|
|
|
|
|
|
|
response := serveHelmRequest(h, "/private/charts/"+digest+"/demo.tgz")
|
|
|
|
|
if response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200: %s", response.Code, response.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := response.Body.String(); got != string(chart) {
|
|
|
|
|
t.Errorf("body = %q, want %q", got, chart)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHelmHandler_FiltersNewChartsFromIndex(t *testing.T) {
|
|
|
|
|
oldDigest := strings.Repeat("a", 64)
|
|
|
|
|
newDigest := strings.Repeat("b", 64)
|
|
|
|
|
proxy := &Proxy{Cooldown: &cooldown.Config{Default: "3d"}}
|
|
|
|
|
h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"test": "https://charts.example"})
|
|
|
|
|
|
|
|
|
|
body := fmt.Sprintf(`apiVersion: v1
|
|
|
|
|
entries:
|
|
|
|
|
demo:
|
|
|
|
|
- created: %s
|
|
|
|
|
digest: %s
|
|
|
|
|
urls: [demo-old.tgz]
|
|
|
|
|
- created: %s
|
|
|
|
|
digest: %s
|
|
|
|
|
urls: [demo-new.tgz]
|
|
|
|
|
`, time.Now().Add(-10*24*time.Hour).Format(time.RFC3339), oldDigest,
|
|
|
|
|
time.Now().Add(-time.Hour).Format(time.RFC3339), newDigest)
|
|
|
|
|
|
|
|
|
|
rewritten, err := h.rewriteIndex("test", "https://charts.example", []byte(body))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteIndex() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(string(rewritten), newDigest) {
|
|
|
|
|
t.Errorf("rewritten index includes a chart still in cooldown: %s", rewritten)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(string(rewritten), oldDigest) {
|
|
|
|
|
t.Errorf("rewritten index omitted an old chart: %s", rewritten)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNormalizeHelmDigest(t *testing.T) {
|
|
|
|
|
digest := strings.Repeat("a", 64)
|
|
|
|
|
for _, input := range []string{digest, "sha256:" + digest, "SHA256:" + strings.ToUpper(digest)} {
|
|
|
|
|
if got, ok := normalizeHelmDigest(input); !ok || got != digest {
|
|
|
|
|
t.Errorf("normalizeHelmDigest(%q) = %q, %t; want %q, true", input, got, ok, digest)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, ok := normalizeHelmDigest("bad"); ok {
|
|
|
|
|
t.Error("normalizeHelmDigest accepted an invalid digest")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHelmIndexEntriesRejectsIncompleteMapping(t *testing.T) {
|
|
|
|
|
entries := &yaml.Node{
|
|
|
|
|
Kind: yaml.MappingNode,
|
|
|
|
|
Content: []*yaml.Node{
|
|
|
|
|
{Kind: yaml.ScalarNode, Value: "demo"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
document := &yaml.Node{
|
|
|
|
|
Kind: yaml.DocumentNode,
|
|
|
|
|
Content: []*yaml.Node{{
|
|
|
|
|
Kind: yaml.MappingNode,
|
|
|
|
|
Content: []*yaml.Node{
|
|
|
|
|
{Kind: yaml.ScalarNode, Value: "entries"},
|
|
|
|
|
entries,
|
|
|
|
|
},
|
|
|
|
|
}},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := helmIndexEntries(document); err == nil {
|
|
|
|
|
t.Fatal("helmIndexEntries() error = nil, want incomplete mapping error")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func serveHelmRequest(h *HelmHandler, target string) *httptest.ResponseRecorder {
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
|
|
|
|
return w
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func helmSHA256Hex(data []byte) string {
|
|
|
|
|
digest := sha256.Sum256(data)
|
|
|
|
|
return hex.EncodeToString(digest[:])
|
|
|
|
|
}
|