From e53535e460fdaa3b3bc4b85f1c08aebafbb8b0bd Mon Sep 17 00:00:00 2001 From: Josh Friend Date: Fri, 1 May 2026 11:56:50 -0400 Subject: [PATCH] feat: add android-sdk caching strategy Add a dedicated Android SDK caching strategy that handles the Android SDK protocol where XML feed/manifest files are mutable and require short TTLs, while archive downloads (.zip files) are immutable and can be cached permanently. The existing proxy strategy does not distinguish between these different cache characteristics, so a dedicated strategy is needed to properly cache SDK feeds with configurable TTLs while permanently caching archives. The strategy: - Routes requests through /android-sdk/{host}/{path...} - Reconstructs the original HTTPS URL and proxies the request - Uses configurable FeedTTL (default 1h) for XML files - Uses permanent caching (TTL=0) for archive files - Follows the same handler.New builder pattern as other strategies --- cachew.hcl | 2 + cmd/cachewd/main.go | 1 + internal/strategy/android_sdk.go | 91 ++++++++++++ internal/strategy/android_sdk_test.go | 196 ++++++++++++++++++++++++++ 4 files changed, 290 insertions(+) create mode 100644 internal/strategy/android_sdk.go create mode 100644 internal/strategy/android_sdk_test.go diff --git a/cachew.hcl b/cachew.hcl index e58eeb7..8b08644 100644 --- a/cachew.hcl +++ b/cachew.hcl @@ -51,6 +51,8 @@ strategy gomod { strategy hermit { } +strategy android-sdk { } + strategy proxy { } cache disk { diff --git a/cmd/cachewd/main.go b/cmd/cachewd/main.go index c1222e8..86db2fa 100644 --- a/cmd/cachewd/main.go +++ b/cmd/cachewd/main.go @@ -141,6 +141,7 @@ func newRegistries( metadatadb.RegisterS3(mr, s3ClientProvider) sr := strategy.NewRegistry() + strategy.RegisterAndroidSDK(sr) strategy.RegisterAPIV1(sr) strategy.RegisterArtifactory(sr) strategy.RegisterGitHubReleases(sr, tokenManagerProvider) diff --git a/internal/strategy/android_sdk.go b/internal/strategy/android_sdk.go new file mode 100644 index 0000000..64ec26c --- /dev/null +++ b/internal/strategy/android_sdk.go @@ -0,0 +1,91 @@ +package strategy + +import ( + "context" + "net/http" + "net/url" + "strings" + "time" + + "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/logging" + "github.com/block/cachew/internal/strategy/handler" +) + +// RegisterAndroidSDK registers the Android SDK caching strategy. +func RegisterAndroidSDK(r *Registry) { + Register(r, "android-sdk", "Caches Android SDK package downloads.", NewAndroidSDK) +} + +// androidSDKArchiveTTL is the TTL used for immutable archive downloads. Archives +// use versioned filenames so a given URL's content never changes. The actual TTL +// is bounded by the cache backend's max-ttl setting. +const androidSDKArchiveTTL = 365 * 24 * time.Hour + +// AndroidSDKConfig holds configuration for the Android SDK caching strategy. +// +// In HCL it looks something like this: +// +// android-sdk { +// feed-ttl = "1h" +// } +type AndroidSDKConfig struct { + // FeedTTL controls how long mutable feed/manifest XML files are cached. + // Archive downloads use a long TTL (1 year, bounded by the cache backend's + // max-ttl). The Android SDK protocol uses XML for all mutable manifests and + // .zip for all immutable archives. + FeedTTL time.Duration `hcl:"feed-ttl,optional" help:"Cache TTL for mutable SDK feed XML files" default:"1h"` +} + +// AndroidSDK caches Android SDK downloads. It routes all requests through +// /android-sdk/{host}/{path...}, reconstructing the original URL and caching +// the response. XML feeds get a short TTL; archive downloads get a long TTL. +type AndroidSDK struct { + config AndroidSDKConfig + cache cache.Cache + client *http.Client +} + +var _ Strategy = (*AndroidSDK)(nil) + +// NewAndroidSDK creates and registers the Android SDK strategy. +func NewAndroidSDK(ctx context.Context, config AndroidSDKConfig, c cache.Cache, mux Mux) (*AndroidSDK, error) { + logger := logging.FromContext(ctx) + + s := &AndroidSDK{ + config: config, + cache: c, + client: &http.Client{}, + } + + hdlr := handler.New(s.client, c). + CacheKey(func(r *http.Request) string { + return s.buildOriginalURL(r) + }). + TTL(func(r *http.Request) time.Duration { + if strings.HasSuffix(r.URL.Path, ".xml") { + return s.config.FeedTTL + } + return androidSDKArchiveTTL + }). + Transform(func(r *http.Request) (*http.Request, error) { + originalURL := s.buildOriginalURL(r) + return http.NewRequestWithContext(r.Context(), http.MethodGet, originalURL, nil) + }) + + mux.Handle("GET /android-sdk/{host}/{path...}", hdlr) + logger.InfoContext(ctx, "Android SDK strategy initialized", "feed_ttl", config.FeedTTL) + return s, nil +} + +// String implements the Strategy interface. +func (s *AndroidSDK) String() string { return "android-sdk" } + +func (s *AndroidSDK) buildOriginalURL(r *http.Request) string { + host := r.PathValue("host") + path := r.PathValue("path") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return (&url.URL{Scheme: "https", Host: host, Path: path, RawQuery: r.URL.RawQuery}).String() +} diff --git a/internal/strategy/android_sdk_test.go b/internal/strategy/android_sdk_test.go new file mode 100644 index 0000000..34df659 --- /dev/null +++ b/internal/strategy/android_sdk_test.go @@ -0,0 +1,196 @@ +package strategy_test + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/alecthomas/assert/v2" + + "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/logging" + "github.com/block/cachew/internal/strategy" +) + +// httpTransportMutexAndroidSDK ensures android-sdk tests don't run in parallel +// since they modify the global http.DefaultTransport +var httpTransportMutexAndroidSDK sync.Mutex //nolint:gochecknoglobals + +type mockAndroidSDKTransport struct { + backend *httptest.Server + originalTransport http.RoundTripper +} + +func (m *mockAndroidSDKTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Host == "example.com" { + newReq := req.Clone(req.Context()) + newReq.URL.Scheme = "http" + newReq.URL.Host = m.backend.Listener.Addr().String() + return m.originalTransport.RoundTrip(newReq) + } + return m.originalTransport.RoundTrip(req) +} + +// ttlSpyCache wraps a cache and records the TTL passed to each Create call. +type ttlSpyCache struct { + cache.Cache + mu sync.Mutex + ttls []time.Duration +} + +func (c *ttlSpyCache) Create(ctx context.Context, key cache.Key, headers http.Header, ttl time.Duration) (io.WriteCloser, error) { + c.mu.Lock() + c.ttls = append(c.ttls, ttl) + c.mu.Unlock() + return c.Cache.Create(ctx, key, headers, ttl) +} + +func setupAndroidSDKWithSpy(t *testing.T, feedTTL time.Duration, backend *httptest.Server) (*http.ServeMux, *ttlSpyCache, context.Context) { + t.Helper() + + httpTransportMutexAndroidSDK.Lock() + t.Cleanup(httpTransportMutexAndroidSDK.Unlock) + + originalTransport := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = originalTransport }) //nolint:reassign + http.DefaultTransport = &mockAndroidSDKTransport{backend: backend, originalTransport: originalTransport} //nolint:reassign + + _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) + memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: time.Hour}) + assert.NoError(t, err) + t.Cleanup(func() { memCache.Close() }) + + spy := &ttlSpyCache{Cache: memCache} + mux := http.NewServeMux() + _, err = strategy.NewAndroidSDK(ctx, strategy.AndroidSDKConfig{FeedTTL: feedTTL}, spy, mux) + assert.NoError(t, err) + + return mux, spy, ctx +} + +func TestAndroidSDKTTLByFileType(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("response")) + })) + defer backend.Close() + + feedTTL := 30 * time.Minute + mux, spy, ctx := setupAndroidSDKWithSpy(t, feedTTL, backend) + + tests := []struct { + name string + path string + expectedTTL time.Duration + }{ + {"XML feed gets FeedTTL", "/android-sdk/example.com/repository2-3.xml", feedTTL}, + {"ZIP archive gets long TTL", "/android-sdk/example.com/platform-36.zip", 365 * 24 * time.Hour}, + {"TXT file gets long TTL", "/android-sdk/example.com/checksums.txt", 365 * 24 * time.Hour}, + {"JAR file gets long TTL", "/android-sdk/example.com/some-tool.jar", 365 * 24 * time.Hour}, + } + + for i, tt := range tests { + req := httptest.NewRequestWithContext(ctx, http.MethodGet, tt.path, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, tt.name) + + spy.mu.Lock() + assert.Equal(t, tt.expectedTTL, spy.ttls[i], tt.name) + spy.mu.Unlock() + } +} + +func TestAndroidSDKCaching(t *testing.T) { + callCount := 0 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("cached-content")) + })) + defer backend.Close() + + mux, _, ctx := setupAndroidSDKWithSpy(t, time.Hour, backend) + + // First request: cache miss + req1 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/android-sdk/example.com/sdk/platform-36_r02.zip", nil) + w1 := httptest.NewRecorder() + mux.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, 1, callCount) + + // Second request: cache hit, no additional backend call + req2 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/android-sdk/example.com/sdk/platform-36_r02.zip", 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") + assert.Equal(t, "cached-content", w2.Body.String()) +} + +func TestAndroidSDKURLReconstruction(t *testing.T) { + var receivedURL string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedURL = r.RequestURI + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + mux, _, ctx := setupAndroidSDKWithSpy(t, time.Hour, backend) + + tests := []struct { + name string + requestPath string + expectedURI string + }{ + {"simple path", "/android-sdk/example.com/path/to/resource.zip", "/path/to/resource.zip"}, + {"with query params", "/android-sdk/example.com/repo.xml?v=2&channel=stable", "/repo.xml?v=2&channel=stable"}, + } + + for _, tt := range tests { + receivedURL = "" + req := httptest.NewRequestWithContext(ctx, http.MethodGet, tt.requestPath, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, tt.name) + assert.Equal(t, tt.expectedURI, receivedURL, tt.name) + } +} + +func TestAndroidSDKMultipleFeedTypes(t *testing.T) { + callCount := 0 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("response")) + })) + defer backend.Close() + + mux, _, ctx := setupAndroidSDKWithSpy(t, time.Hour, backend) + + paths := []string{ + "/android-sdk/example.com/repository2-3.xml", + "/android-sdk/example.com/platform-36.zip", + "/android-sdk/example.com/checksums.txt", + } + + for i, path := range paths { + req := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, i+1, callCount) + + // Second request should always be cached + req2 := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil) + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, req2) + assert.Equal(t, i+1, callCount, "path %s should be served from cache", path) + } +}