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
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1048,7 +1048,7 @@ RubyGems, Hex and PyPI keep their publish times outside the metadata clients rea

With cooldown on, the proxy asks npm for full packuments, since the abbreviated ones it uses otherwise carry no publish times. Abbreviated packuments cached before cooldown was turned on are fetched again in full rather than served, including as a stale copy when the upstream is down.

Conda cooldown covers `repodata.json` and sharded repodata (CEP 16). Clients cache shards by the hash in the shard index, so with cooldown on the proxy serves a rewritten index whose shard hashes change every hour and filters each shard as it is fetched. Versions leaving cooldown show up within the hour, and clients fetch the shards they need again once an hour. If a channel's shard index points packages or shards somewhere the proxy doesn't route, the proxy answers 404 for it and clients fall back to `repodata.json`.
Conda cooldown covers `repodata.json`, `current_repodata.json`, `repodata_from_packages.json` and sharded repodata (CEP 16). Other forms of the repodata, such as `repodata.json.zst`, `repodata.json.bz2` and `repodata.jlap` patches, would list versions still in cooldown, so with cooldown on they are answered 404 and conda and mamba fall back to `repodata.json`. Clients cache shards by the hash in the shard index, so with cooldown on the proxy serves a rewritten index whose shard hashes change every hour and filters each shard as it is fetched. Versions leaving cooldown show up within the hour, and clients fetch the shards they need again once an hour. If a channel's shard index points packages or shards somewhere the proxy doesn't route, the proxy answers 404 for it and clients fall back to `repodata.json`.

Note: Hex cooldown requires disabling registry signature verification since the proxy re-encodes the protobuf payload without the original signature. Set `HEX_NO_VERIFY_REPO_ORIGIN=1` or configure your repo with `no_verify: true`.

Expand Down
34 changes: 32 additions & 2 deletions internal/handler/conda.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ func (h *CondaHandler) Routes() http.Handler {

// Channel index (repodata)
mux.HandleFunc("GET /{channel}/{arch}/repodata.json", h.handleRepodata)
mux.HandleFunc("GET /{channel}/{arch}/repodata.json.bz2", h.proxyCached)
mux.HandleFunc("GET /{channel}/{arch}/repodata.json.bz2", h.handleRepodataVariant)
mux.HandleFunc("GET /{channel}/{arch}/current_repodata.json", h.handleRepodata)
mux.HandleFunc("GET /{channel}/{arch}/repodata_from_packages.json", h.handleRepodata)

// Sharded repodata (CEP 16)
mux.HandleFunc("GET /{channel}/{arch}/"+condaShardIndexFile, h.handleShardIndex)
Expand All @@ -106,6 +107,10 @@ func (h *CondaHandler) handleDownload(w http.ResponseWriter, r *http.Request) {

// Only cache actual package files
if !h.isPackageFile(filename) {
if isCondaRepodataVariant(filename) && h.cooldownEnabled() {
refuseUnfilteredRepodata(w)
return
}
h.proxyUpstream(w, r)
return
}
Expand Down Expand Up @@ -178,9 +183,34 @@ func (h *CondaHandler) parseFilename(filename string) (name, version string) {
return name, version
}

// isCondaRepodataVariant reports whether filename is a form of a subdir's
// repodata other than the plain JSON documents the proxy filters, such as
// repodata.json.zst, repodata.json.bz2 or repodata.jlap patches. Package
// files are told apart by the caller.
func isCondaRepodataVariant(filename string) bool {
return strings.HasPrefix(filename, "repodata") || strings.HasPrefix(filename, "current_repodata")
}

// handleRepodataVariant serves repodata.json.bz2 from the metadata cache,
// unless cooldown is on: the proxy filters only the JSON, so a compressed
// copy would list versions still in cooldown.
func (h *CondaHandler) handleRepodataVariant(w http.ResponseWriter, r *http.Request) {
if h.cooldownEnabled() {
refuseUnfilteredRepodata(w)
return
}
h.proxyCached(w, r)
}

// refuseUnfilteredRepodata answers 404 for a repodata variant the proxy
// can't filter. conda and mamba then fall back to repodata.json.
func refuseUnfilteredRepodata(w http.ResponseWriter) {
http.Error(w, "only repodata.json is served while cooldown is enabled", http.StatusNotFound)
}

// handleRepodata proxies repodata.json, applying cooldown filtering when enabled.
func (h *CondaHandler) handleRepodata(w http.ResponseWriter, r *http.Request) {
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
if !h.cooldownEnabled() {
h.proxyCached(w, r)
return
}
Expand Down
76 changes: 76 additions & 0 deletions internal/handler/conda_repodata_variants_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package handler

import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

"github.com/git-pkgs/cooldown"
)

// The proxy filters only the plain JSON repodata. With cooldown on, any
// other form of it would list versions still in cooldown, so it is refused
// and clients fall back to repodata.json.
func TestCondaRepodataVariantsUnderCooldown(t *testing.T) {
plain, err := json.Marshal(map[string]any{
"packages": map[string]any{
"old.tar.bz2": map[string]any{"name": "demo", "timestamp": time.Now().Add(-7 * 24 * time.Hour).UnixMilli()},
"new.tar.bz2": map[string]any{"name": "demo", "timestamp": time.Now().UnixMilli()},
},
})
if err != nil {
t.Fatal(err)
}
var requests atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
_, _ = w.Write(plain)
}))
defer upstream.Close()

variants := []string{
"repodata.json.bz2",
"repodata.json.zst",
"repodata.jlap",
"current_repodata.json.zst",
"repodata_from_packages.json.zst",
}
for _, enabled := range []bool{true, false} {
proxy := testProxy()
proxy.HTTPClient = upstream.Client()
if enabled {
proxy.Cooldown = &cooldown.Config{Default: "3d"}
}
handler := NewCondaHandlerWithUpstream(proxy, "http://proxy.local", upstream.URL).Routes()
for _, filename := range variants {
requests.Store(0)
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conda-forge/linux-64/"+filename, nil))
switch {
case enabled && (w.Code != http.StatusNotFound || requests.Load() != 0):
t.Errorf("cooldown on, %s: status %d, %d upstream requests; want 404 without asking upstream", filename, w.Code, requests.Load())
case !enabled && (w.Code != http.StatusOK || requests.Load() != 1):
t.Errorf("cooldown off, %s: status %d, %d upstream requests; want it passed through", filename, w.Code, requests.Load())
}
}
}

// repodata_from_packages.json is plain JSON and is filtered like
// repodata.json.
proxy := testProxy()
proxy.HTTPClient = upstream.Client()
proxy.Cooldown = &cooldown.Config{Default: "3d"}
handler := NewCondaHandlerWithUpstream(proxy, "http://proxy.local", upstream.URL).Routes()
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conda-forge/linux-64/repodata_from_packages.json", nil))
var result map[string]map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("status %d, body %s: %v", w.Code, w.Body, err)
}
if len(result["packages"]) != 1 || result["packages"]["old.tar.bz2"] == nil {
t.Errorf("repodata_from_packages.json not filtered: %s", w.Body)
}
}
Loading