From 5a9bbb3c42519b9261f7390bf2ce6d464b44559f Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Wed, 22 Apr 2026 16:46:13 +1000 Subject: [PATCH] fix: add short TTL for Hermit self-update binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hermit binary URL (hermit-{os}-{arch}.gz) is mutable — the same URL serves different versions as Hermit is updated. Without a TTL, Cachew caches the binary indefinitely (up to the global max-ttl of 24-72h), causing hermit update --self-update to downgrade to a stale version. This adds a configurable binary-ttl (default 1h) that applies only to the Hermit binary downloads, while leaving versioned package downloads with the default (long) TTL. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019db3c3-6d2b-70f9-a2ba-46dab43a882f --- internal/strategy/hermit.go | 16 +++++++++- internal/strategy/hermit_test.go | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/internal/strategy/hermit.go b/internal/strategy/hermit.go index 277e5e1..2398b6c 100644 --- a/internal/strategy/hermit.go +++ b/internal/strategy/hermit.go @@ -5,7 +5,9 @@ import ( "log/slog" "net/http" "net/url" + "regexp" "strings" + "time" "github.com/alecthomas/errors" @@ -23,8 +25,14 @@ func RegisterHermit(r *Registry) { const defaultGitHubBaseURL = "http://127.0.0.1:8080/github.com" +// hermitBinaryPattern matches the Hermit self-update binary (e.g. hermit-linux-amd64.gz). +// Unlike versioned package downloads, this URL is mutable — the same path serves +// different versions over time — so it needs a short cache TTL. +var hermitBinaryPattern = regexp.MustCompile(`/hermit-[a-z]+-[a-z0-9]+\.gz$`) + type HermitConfig struct { - GitHubBaseURL string `hcl:"github-base-url" help:"Base URL for GitHub release redirects" default:"http://127.0.0.1:8080/github.com"` + GitHubBaseURL string `hcl:"github-base-url" help:"Base URL for GitHub release redirects" default:"http://127.0.0.1:8080/github.com"` + BinaryTTL time.Duration `hcl:"binary-ttl,optional" help:"Cache TTL for the mutable Hermit self-update binary" default:"1h"` } // Hermit caches Hermit package downloads. @@ -76,6 +84,12 @@ func (s *Hermit) createDirectHandler(c cache.Cache) http.Handler { CacheKey(func(r *http.Request) string { return s.buildOriginalURL(r) }). + TTL(func(r *http.Request) time.Duration { + if hermitBinaryPattern.MatchString(r.URL.Path) { + return s.config.BinaryTTL + } + return 0 + }). Transform(func(r *http.Request) (*http.Request, error) { return s.buildDirectRequest(r) }) diff --git a/internal/strategy/hermit_test.go b/internal/strategy/hermit_test.go index 4c77f8d..1f94e55 100644 --- a/internal/strategy/hermit_test.go +++ b/internal/strategy/hermit_test.go @@ -36,6 +36,59 @@ func setupHermitTest(t *testing.T) (*http.ServeMux, context.Context, cache.Cache return mux, ctx, memCache } +func TestHermitBinaryShortTTL(t *testing.T) { + httpTransportMutexHermit.Lock() + defer httpTransportMutexHermit.Unlock() + + callCount := 0 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hermit-binary")) + })) + defer backend.Close() + + originalTransport := http.DefaultTransport + defer func() { http.DefaultTransport = originalTransport }() //nolint:reassign + http.DefaultTransport = &mockTransport{backend: backend, originalTransport: originalTransport} //nolint:reassign + + _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) + memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: 100 * time.Millisecond}) + assert.NoError(t, err) + t.Cleanup(func() { memCache.Close() }) + + mux := http.NewServeMux() + _, err = strategy.NewHermit(ctx, strategy.HermitConfig{ + GitHubBaseURL: "http://localhost:8080", + BinaryTTL: 100 * time.Millisecond, + }, nil, memCache, mux) + assert.NoError(t, err) + + // First request: cache miss + req1 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/hermit/example.com/square/hermit-linux-amd64.gz", nil) + w1 := httptest.NewRecorder() + mux.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, 1, callCount) + + // Second request: cache hit + req2 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/hermit/example.com/square/hermit-linux-amd64.gz", nil) + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code) + assert.Equal(t, 1, callCount, "should be served from cache") + + // Wait for TTL to expire + time.Sleep(150 * time.Millisecond) + + // Third request: cache miss after TTL expiry + req3 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/hermit/example.com/square/hermit-linux-amd64.gz", nil) + w3 := httptest.NewRecorder() + mux.ServeHTTP(w3, req3) + assert.Equal(t, http.StatusOK, w3.Code) + assert.Equal(t, 2, callCount, "should re-fetch after TTL expiry") +} + func TestHermitNonGitHubCaching(t *testing.T) { httpTransportMutexHermit.Lock() defer httpTransportMutexHermit.Unlock()