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()