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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ As Git itself isn't aware of the snapshots, Git-specific code in the Cachew CLI

## Hermit

Hermit
Caches Hermit package downloads from all sources (golang.org, npm, GitHub releases, etc.).

**URL pattern:** `/hermit/{host}/{path...}`

Example: `GET /hermit/golang.org/dl/go1.21.0.tar.gz`

GitHub releases are automatically redirected to the `github-releases` strategy.
4 changes: 3 additions & 1 deletion cachew-local.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ disk {

gomod {
proxy = "https://proxy.golang.org"
}
}

hermit { }
2 changes: 2 additions & 0 deletions cachew.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ github-releases {
gomod {
proxy = "https://proxy.golang.org"
}

hermit { }
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/block/cachew
go 1.25.5

require (
github.com/alecthomas/hcl/v2 v2.3.1
github.com/alecthomas/hcl/v2 v2.4.0
github.com/alecthomas/kong v1.13.0
github.com/goproxy/goproxy v0.25.0
github.com/lmittmann/tint v1.1.2
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/errors v0.9.1 h1:JNXtU30rtMNARCkW41OTZ4yL6Lyocq20xIJgIw2raqI=
github.com/alecthomas/errors v0.9.1/go.mod h1:l8mjMEHMGUdIWPMNtvDyRYPVS1fQFXHFXc/iVCCLGkI=
github.com/alecthomas/hcl/v2 v2.3.1 h1:Nkj0svGJawz920nQyWUhD2PYmD47p7BB9vc2e3kft1o=
github.com/alecthomas/hcl/v2 v2.3.1/go.mod h1:4UUp66q8ony5j8tm2bANErujUpZ3GgHBLgaKxTUQlQI=
github.com/alecthomas/hcl/v2 v2.4.0 h1:j7sPnff/f6FLAPTZmpFzHS2ENwE/dHj6K40bRb9nk4g=
github.com/alecthomas/hcl/v2 v2.4.0/go.mod h1:4UUp66q8ony5j8tm2bANErujUpZ3GgHBLgaKxTUQlQI=
github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA=
github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
Expand Down
62 changes: 62 additions & 0 deletions internal/cache/noop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package cache

import (
"context"
"io"
"net/http"
"os"
"time"
)

// noOpCache is a cache implementation that doesn't cache anything.
// It always returns cache misses and discards writes.
// Useful for pass-through handlers that shouldn't cache.
type noOpCache struct{}

// NoOpCache returns a cache that doesn't cache anything.
// All Open() calls return os.ErrNotExist (cache miss).
// All Create() calls return a writer that discards data.
func NoOpCache() Cache {
return &noOpCache{}
}

func (n *noOpCache) String() string { return "noop" }

func (n *noOpCache) Stat(_ context.Context, _ Key) (http.Header, error) {
return nil, os.ErrNotExist
}

func (n *noOpCache) Open(_ context.Context, _ Key) (io.ReadCloser, http.Header, error) {
return nil, nil, os.ErrNotExist
}

func (n *noOpCache) Create(_ context.Context, _ Key, _ http.Header, _ time.Duration) (io.WriteCloser, error) {
// Return a discard writer that does nothing
return &noOpWriter{}, nil
}

func (n *noOpCache) Delete(_ context.Context, _ Key) error {
return nil
}

func (n *noOpCache) Stats(_ context.Context) (Stats, error) {
return Stats{}, ErrStatsUnavailable
}

func (n *noOpCache) Close() error {
return nil
}

// noOpWriter is a writer that discards all data.
type noOpWriter struct{}

func (n *noOpWriter) Write(p []byte) (int, error) {
return len(p), nil
}

func (n *noOpWriter) Close() error {
return nil
}

var _ Cache = (*noOpCache)(nil)
var _ io.WriteCloser = (*noOpWriter)(nil)
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func Load(ctx context.Context, cr *cache.Registry, r io.Reader, scheduler jobsch
for _, block := range strategyCandidates {
logger := logger.With("strategy", block.Name)
mlog := &loggingMux{logger: logger, mux: mux}
_, err := strategy.Create(ctx, block.Name, block, scheduler.WithQueuePrefix(block.Name), cache, mlog)
_, err := strategy.Create(ctx, block.Name, block, scheduler.WithQueuePrefix(block.Name), cache, mlog, vars)
if err != nil {
return errors.Errorf("%s: %w", block.Pos, err)
}
Expand Down
13 changes: 9 additions & 4 deletions internal/strategy/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package strategy
import (
"context"
"net/http"
"os"

"github.com/alecthomas/errors"
"github.com/alecthomas/hcl/v2"
Expand All @@ -22,7 +23,7 @@ type Mux interface {

type registryEntry struct {
schema *hcl.Block
factory func(ctx context.Context, config *hcl.Block, scheduler jobscheduler.Scheduler, cache cache.Cache, mux Mux) (Strategy, error)
factory func(ctx context.Context, config *hcl.Block, scheduler jobscheduler.Scheduler, cache cache.Cache, mux Mux, vars map[string]string) (Strategy, error)
}

var registry = map[string]registryEntry{}
Expand All @@ -40,9 +41,12 @@ func Register[Config any, S Strategy](id, description string, factory Factory[Co
block.Comments = hcl.CommentList{description}
registry[id] = registryEntry{
schema: block,
factory: func(ctx context.Context, config *hcl.Block, scheduler jobscheduler.Scheduler, cache cache.Cache, mux Mux) (Strategy, error) {
factory: func(ctx context.Context, config *hcl.Block, scheduler jobscheduler.Scheduler, cache cache.Cache, mux Mux, vars map[string]string) (Strategy, error) {
var cfg Config
if err := hcl.UnmarshalBlock(config, &cfg, hcl.AllowExtra(false)); err != nil {
transformer := func(defaultValue string) string {
return os.Expand(defaultValue, func(key string) string { return vars[key] })
}
if err := hcl.UnmarshalBlock(config, &cfg, hcl.AllowExtra(false), hcl.WithDefaultTransformer(transformer)); err != nil {
return nil, errors.WithStack(err)
}
return factory(ctx, cfg, scheduler, cache, mux)
Expand All @@ -69,9 +73,10 @@ func Create(
scheduler jobscheduler.Scheduler,
cache cache.Cache,
mux Mux,
vars map[string]string,
) (Strategy, error) {
if entry, ok := registry[name]; ok {
return errors.WithStack2(entry.factory(ctx, config, scheduler.WithQueuePrefix(name), cache, mux))
return errors.WithStack2(entry.factory(ctx, config, scheduler.WithQueuePrefix(name), cache, mux, vars))
}
return nil, errors.Errorf("%s: %w", name, ErrNotFound)
}
Expand Down
152 changes: 152 additions & 0 deletions internal/strategy/hermit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package strategy

import (
"context"
"log/slog"
"net/http"
"net/url"
"os"
"strings"

"github.com/alecthomas/errors"

"github.com/block/cachew/internal/cache"
"github.com/block/cachew/internal/jobscheduler"
"github.com/block/cachew/internal/logging"
"github.com/block/cachew/internal/strategy/handler"
)

func init() {
Register("hermit", "Caches Hermit package downloads.", NewHermit)
}

type HermitConfig struct {
GitHubBaseURL string `hcl:"github-base-url" help:"Base URL for GitHub release redirects" default:"${CACHEW_URL}/github.com"`
}

// Hermit caches Hermit package downloads.
// Acts as a smart router: GitHub releases redirect to github-releases strategy,
// all other sources are handled directly.
type Hermit struct {
config HermitConfig
cache cache.Cache
client *http.Client
logger *slog.Logger
mux Mux
redirectHandler http.Handler
directHandler http.Handler
}

var _ Strategy = (*Hermit)(nil)

func NewHermit(ctx context.Context, config HermitConfig, _ jobscheduler.Scheduler, c cache.Cache, mux Mux) (*Hermit, error) {
logger := logging.FromContext(ctx)

s := &Hermit{
config: config,
cache: c,
client: http.DefaultClient,
logger: logger,
mux: mux,
}

s.directHandler = s.createDirectHandler(c)
mux.Handle("GET /hermit/{host}/{path...}", s.directHandler)

if config.GitHubBaseURL != "" {
isInternalRedirect := strings.Contains(config.GitHubBaseURL, os.Getenv("CACHEW_URL"))
s.redirectHandler = s.createRedirectHandler(isInternalRedirect, c)
mux.Handle("GET /hermit/github.com/{path...}", s.redirectHandler)
logger.InfoContext(ctx, "Hermit strategy initialized",
slog.String("github_base_url", config.GitHubBaseURL),
slog.Bool("internal_redirect", isInternalRedirect))
} else {
logger.InfoContext(ctx, "Hermit strategy initialized")
}

return s, nil
}

func (s *Hermit) String() string { return "hermit" }

func (s *Hermit) createDirectHandler(c cache.Cache) http.Handler {
return handler.New(s.client, c).
CacheKey(func(r *http.Request) string {
return s.buildOriginalURL(r)
}).
Transform(func(r *http.Request) (*http.Request, error) {
return s.buildDirectRequest(r)
})
}

func (s *Hermit) createRedirectHandler(isInternalRedirect bool, c cache.Cache) http.Handler {
var cacheBackend cache.Cache
if isInternalRedirect {
cacheBackend = cache.NoOpCache()
} else {
cacheBackend = c
}

return handler.New(s.client, cacheBackend).
CacheKey(func(r *http.Request) string {
return s.buildGitHubURL(r)
}).
Transform(func(r *http.Request) (*http.Request, error) {
s.logger.DebugContext(r.Context(), "Redirect handler called for GitHub release")
return s.buildRedirectRequest(r)
})
}

func (s *Hermit) buildGitHubURL(r *http.Request) string {
return buildURL("https", "github.com", r.PathValue("path"), r.URL.RawQuery)
}

func (s *Hermit) buildRedirectRequest(r *http.Request) (*http.Request, error) {
path := ensureLeadingSlash(r.PathValue("path"))
redirectURL := s.config.GitHubBaseURL + path
if r.URL.RawQuery != "" {
redirectURL += "?" + r.URL.RawQuery
}

req, err := http.NewRequestWithContext(r.Context(), r.Method, redirectURL, nil)
if err != nil {
return nil, errors.Wrap(err, "create internal redirect request")
}

req.Header = r.Header.Clone()
return req, nil
}

func (s *Hermit) buildDirectRequest(r *http.Request) (*http.Request, error) {
originalURL := s.buildOriginalURL(r)

s.logger.DebugContext(r.Context(), "Fetching Hermit package",
slog.String("url", originalURL))

req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, originalURL, nil)
if err != nil {
return nil, errors.Wrap(err, "create request")
}
return req, nil
}

func (s *Hermit) buildOriginalURL(r *http.Request) string {
return buildURL("https", r.PathValue("host"), r.PathValue("path"), r.URL.RawQuery)
}

func buildURL(scheme, host, path, query string) string {
u := &url.URL{
Scheme: scheme,
Host: host,
Path: ensureLeadingSlash(path),
RawQuery: query,
}
return u.String()
}

func ensureLeadingSlash(path string) string {
if !strings.HasPrefix(path, "/") {
return "/" + path
}
return path
}
Loading