Skip to content

Commit e53535e

Browse files
committed
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
1 parent 9e485bf commit e53535e

4 files changed

Lines changed: 290 additions & 0 deletions

File tree

cachew.hcl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ strategy gomod {
5151

5252
strategy hermit { }
5353

54+
strategy android-sdk { }
55+
5456
strategy proxy { }
5557

5658
cache disk {

cmd/cachewd/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ func newRegistries(
141141
metadatadb.RegisterS3(mr, s3ClientProvider)
142142

143143
sr := strategy.NewRegistry()
144+
strategy.RegisterAndroidSDK(sr)
144145
strategy.RegisterAPIV1(sr)
145146
strategy.RegisterArtifactory(sr)
146147
strategy.RegisterGitHubReleases(sr, tokenManagerProvider)

internal/strategy/android_sdk.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package strategy
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/url"
7+
"strings"
8+
"time"
9+
10+
"github.com/block/cachew/internal/cache"
11+
"github.com/block/cachew/internal/logging"
12+
"github.com/block/cachew/internal/strategy/handler"
13+
)
14+
15+
// RegisterAndroidSDK registers the Android SDK caching strategy.
16+
func RegisterAndroidSDK(r *Registry) {
17+
Register(r, "android-sdk", "Caches Android SDK package downloads.", NewAndroidSDK)
18+
}
19+
20+
// androidSDKArchiveTTL is the TTL used for immutable archive downloads. Archives
21+
// use versioned filenames so a given URL's content never changes. The actual TTL
22+
// is bounded by the cache backend's max-ttl setting.
23+
const androidSDKArchiveTTL = 365 * 24 * time.Hour
24+
25+
// AndroidSDKConfig holds configuration for the Android SDK caching strategy.
26+
//
27+
// In HCL it looks something like this:
28+
//
29+
// android-sdk {
30+
// feed-ttl = "1h"
31+
// }
32+
type AndroidSDKConfig struct {
33+
// FeedTTL controls how long mutable feed/manifest XML files are cached.
34+
// Archive downloads use a long TTL (1 year, bounded by the cache backend's
35+
// max-ttl). The Android SDK protocol uses XML for all mutable manifests and
36+
// .zip for all immutable archives.
37+
FeedTTL time.Duration `hcl:"feed-ttl,optional" help:"Cache TTL for mutable SDK feed XML files" default:"1h"`
38+
}
39+
40+
// AndroidSDK caches Android SDK downloads. It routes all requests through
41+
// /android-sdk/{host}/{path...}, reconstructing the original URL and caching
42+
// the response. XML feeds get a short TTL; archive downloads get a long TTL.
43+
type AndroidSDK struct {
44+
config AndroidSDKConfig
45+
cache cache.Cache
46+
client *http.Client
47+
}
48+
49+
var _ Strategy = (*AndroidSDK)(nil)
50+
51+
// NewAndroidSDK creates and registers the Android SDK strategy.
52+
func NewAndroidSDK(ctx context.Context, config AndroidSDKConfig, c cache.Cache, mux Mux) (*AndroidSDK, error) {
53+
logger := logging.FromContext(ctx)
54+
55+
s := &AndroidSDK{
56+
config: config,
57+
cache: c,
58+
client: &http.Client{},
59+
}
60+
61+
hdlr := handler.New(s.client, c).
62+
CacheKey(func(r *http.Request) string {
63+
return s.buildOriginalURL(r)
64+
}).
65+
TTL(func(r *http.Request) time.Duration {
66+
if strings.HasSuffix(r.URL.Path, ".xml") {
67+
return s.config.FeedTTL
68+
}
69+
return androidSDKArchiveTTL
70+
}).
71+
Transform(func(r *http.Request) (*http.Request, error) {
72+
originalURL := s.buildOriginalURL(r)
73+
return http.NewRequestWithContext(r.Context(), http.MethodGet, originalURL, nil)
74+
})
75+
76+
mux.Handle("GET /android-sdk/{host}/{path...}", hdlr)
77+
logger.InfoContext(ctx, "Android SDK strategy initialized", "feed_ttl", config.FeedTTL)
78+
return s, nil
79+
}
80+
81+
// String implements the Strategy interface.
82+
func (s *AndroidSDK) String() string { return "android-sdk" }
83+
84+
func (s *AndroidSDK) buildOriginalURL(r *http.Request) string {
85+
host := r.PathValue("host")
86+
path := r.PathValue("path")
87+
if !strings.HasPrefix(path, "/") {
88+
path = "/" + path
89+
}
90+
return (&url.URL{Scheme: "https", Host: host, Path: path, RawQuery: r.URL.RawQuery}).String()
91+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package strategy_test
2+
3+
import (
4+
"context"
5+
"io"
6+
"log/slog"
7+
"net/http"
8+
"net/http/httptest"
9+
"sync"
10+
"testing"
11+
"time"
12+
13+
"github.com/alecthomas/assert/v2"
14+
15+
"github.com/block/cachew/internal/cache"
16+
"github.com/block/cachew/internal/logging"
17+
"github.com/block/cachew/internal/strategy"
18+
)
19+
20+
// httpTransportMutexAndroidSDK ensures android-sdk tests don't run in parallel
21+
// since they modify the global http.DefaultTransport
22+
var httpTransportMutexAndroidSDK sync.Mutex //nolint:gochecknoglobals
23+
24+
type mockAndroidSDKTransport struct {
25+
backend *httptest.Server
26+
originalTransport http.RoundTripper
27+
}
28+
29+
func (m *mockAndroidSDKTransport) RoundTrip(req *http.Request) (*http.Response, error) {
30+
if req.Host == "example.com" {
31+
newReq := req.Clone(req.Context())
32+
newReq.URL.Scheme = "http"
33+
newReq.URL.Host = m.backend.Listener.Addr().String()
34+
return m.originalTransport.RoundTrip(newReq)
35+
}
36+
return m.originalTransport.RoundTrip(req)
37+
}
38+
39+
// ttlSpyCache wraps a cache and records the TTL passed to each Create call.
40+
type ttlSpyCache struct {
41+
cache.Cache
42+
mu sync.Mutex
43+
ttls []time.Duration
44+
}
45+
46+
func (c *ttlSpyCache) Create(ctx context.Context, key cache.Key, headers http.Header, ttl time.Duration) (io.WriteCloser, error) {
47+
c.mu.Lock()
48+
c.ttls = append(c.ttls, ttl)
49+
c.mu.Unlock()
50+
return c.Cache.Create(ctx, key, headers, ttl)
51+
}
52+
53+
func setupAndroidSDKWithSpy(t *testing.T, feedTTL time.Duration, backend *httptest.Server) (*http.ServeMux, *ttlSpyCache, context.Context) {
54+
t.Helper()
55+
56+
httpTransportMutexAndroidSDK.Lock()
57+
t.Cleanup(httpTransportMutexAndroidSDK.Unlock)
58+
59+
originalTransport := http.DefaultTransport
60+
t.Cleanup(func() { http.DefaultTransport = originalTransport }) //nolint:reassign
61+
http.DefaultTransport = &mockAndroidSDKTransport{backend: backend, originalTransport: originalTransport} //nolint:reassign
62+
63+
_, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError})
64+
memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: time.Hour})
65+
assert.NoError(t, err)
66+
t.Cleanup(func() { memCache.Close() })
67+
68+
spy := &ttlSpyCache{Cache: memCache}
69+
mux := http.NewServeMux()
70+
_, err = strategy.NewAndroidSDK(ctx, strategy.AndroidSDKConfig{FeedTTL: feedTTL}, spy, mux)
71+
assert.NoError(t, err)
72+
73+
return mux, spy, ctx
74+
}
75+
76+
func TestAndroidSDKTTLByFileType(t *testing.T) {
77+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
78+
w.WriteHeader(http.StatusOK)
79+
_, _ = w.Write([]byte("response"))
80+
}))
81+
defer backend.Close()
82+
83+
feedTTL := 30 * time.Minute
84+
mux, spy, ctx := setupAndroidSDKWithSpy(t, feedTTL, backend)
85+
86+
tests := []struct {
87+
name string
88+
path string
89+
expectedTTL time.Duration
90+
}{
91+
{"XML feed gets FeedTTL", "/android-sdk/example.com/repository2-3.xml", feedTTL},
92+
{"ZIP archive gets long TTL", "/android-sdk/example.com/platform-36.zip", 365 * 24 * time.Hour},
93+
{"TXT file gets long TTL", "/android-sdk/example.com/checksums.txt", 365 * 24 * time.Hour},
94+
{"JAR file gets long TTL", "/android-sdk/example.com/some-tool.jar", 365 * 24 * time.Hour},
95+
}
96+
97+
for i, tt := range tests {
98+
req := httptest.NewRequestWithContext(ctx, http.MethodGet, tt.path, nil)
99+
w := httptest.NewRecorder()
100+
mux.ServeHTTP(w, req)
101+
assert.Equal(t, http.StatusOK, w.Code, tt.name)
102+
103+
spy.mu.Lock()
104+
assert.Equal(t, tt.expectedTTL, spy.ttls[i], tt.name)
105+
spy.mu.Unlock()
106+
}
107+
}
108+
109+
func TestAndroidSDKCaching(t *testing.T) {
110+
callCount := 0
111+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
112+
callCount++
113+
w.WriteHeader(http.StatusOK)
114+
_, _ = w.Write([]byte("cached-content"))
115+
}))
116+
defer backend.Close()
117+
118+
mux, _, ctx := setupAndroidSDKWithSpy(t, time.Hour, backend)
119+
120+
// First request: cache miss
121+
req1 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/android-sdk/example.com/sdk/platform-36_r02.zip", nil)
122+
w1 := httptest.NewRecorder()
123+
mux.ServeHTTP(w1, req1)
124+
assert.Equal(t, http.StatusOK, w1.Code)
125+
assert.Equal(t, 1, callCount)
126+
127+
// Second request: cache hit, no additional backend call
128+
req2 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/android-sdk/example.com/sdk/platform-36_r02.zip", nil)
129+
w2 := httptest.NewRecorder()
130+
mux.ServeHTTP(w2, req2)
131+
assert.Equal(t, http.StatusOK, w2.Code)
132+
assert.Equal(t, 1, callCount, "should be served from cache")
133+
assert.Equal(t, "cached-content", w2.Body.String())
134+
}
135+
136+
func TestAndroidSDKURLReconstruction(t *testing.T) {
137+
var receivedURL string
138+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139+
receivedURL = r.RequestURI
140+
w.WriteHeader(http.StatusOK)
141+
_, _ = w.Write([]byte("ok"))
142+
}))
143+
defer backend.Close()
144+
145+
mux, _, ctx := setupAndroidSDKWithSpy(t, time.Hour, backend)
146+
147+
tests := []struct {
148+
name string
149+
requestPath string
150+
expectedURI string
151+
}{
152+
{"simple path", "/android-sdk/example.com/path/to/resource.zip", "/path/to/resource.zip"},
153+
{"with query params", "/android-sdk/example.com/repo.xml?v=2&channel=stable", "/repo.xml?v=2&channel=stable"},
154+
}
155+
156+
for _, tt := range tests {
157+
receivedURL = ""
158+
req := httptest.NewRequestWithContext(ctx, http.MethodGet, tt.requestPath, nil)
159+
w := httptest.NewRecorder()
160+
mux.ServeHTTP(w, req)
161+
assert.Equal(t, http.StatusOK, w.Code, tt.name)
162+
assert.Equal(t, tt.expectedURI, receivedURL, tt.name)
163+
}
164+
}
165+
166+
func TestAndroidSDKMultipleFeedTypes(t *testing.T) {
167+
callCount := 0
168+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
169+
callCount++
170+
w.WriteHeader(http.StatusOK)
171+
_, _ = w.Write([]byte("response"))
172+
}))
173+
defer backend.Close()
174+
175+
mux, _, ctx := setupAndroidSDKWithSpy(t, time.Hour, backend)
176+
177+
paths := []string{
178+
"/android-sdk/example.com/repository2-3.xml",
179+
"/android-sdk/example.com/platform-36.zip",
180+
"/android-sdk/example.com/checksums.txt",
181+
}
182+
183+
for i, path := range paths {
184+
req := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil)
185+
w := httptest.NewRecorder()
186+
mux.ServeHTTP(w, req)
187+
assert.Equal(t, http.StatusOK, w.Code)
188+
assert.Equal(t, i+1, callCount)
189+
190+
// Second request should always be cached
191+
req2 := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil)
192+
w2 := httptest.NewRecorder()
193+
mux.ServeHTTP(w2, req2)
194+
assert.Equal(t, i+1, callCount, "path %s should be served from cache", path)
195+
}
196+
}

0 commit comments

Comments
 (0)