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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
go.opentelemetry.io/otel/metric v1.40.0
go.opentelemetry.io/otel/sdk v1.40.0
go.opentelemetry.io/otel/sdk/metric v1.40.0
golang.org/x/mod v0.31.0
)

require (
Expand Down Expand Up @@ -50,7 +51,6 @@ require (
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.32.0 // indirect
Expand Down
31 changes: 31 additions & 0 deletions internal/gitclone/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ import (
"github.com/alecthomas/errors"
)

var (
sharedManager *Manager
sharedManagerMu sync.RWMutex
)

func SetShared(m *Manager) {
sharedManagerMu.Lock()
defer sharedManagerMu.Unlock()
sharedManager = m
}

func GetShared() *Manager {
sharedManagerMu.RLock()
defer sharedManagerMu.RUnlock()
return sharedManager
}
Comment on lines +18 to +33
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't tell why this exists. We should be constructing a single Manager in main, then passing it everywhere, so AFAICT we shouldn't need this?

Copy link
Collaborator Author

@js-murph js-murph Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of where things load and where things need to be constructed, this gets more complicated than you might think. I'll address this as a separate PR because I think it might be easier to illustrate the problem and then we can work out what to do.


type State int

const (
Expand Down Expand Up @@ -132,6 +149,10 @@ func (m *Manager) Get(upstreamURL string) *Repository {
return m.clones[upstreamURL]
}

func (m *Manager) Config() Config {
return m.config
}

func (m *Manager) DiscoverExisting(_ context.Context) error {
err := filepath.Walk(m.config.RootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
Expand Down Expand Up @@ -429,3 +450,13 @@ func (r *Repository) GetUpstreamRefs(ctx context.Context) (map[string]string, er

return ParseGitRefs(output), nil
}

func (r *Repository) HasCommit(ctx context.Context, ref string) bool {
r.mu.RLock()
defer r.mu.RUnlock()

// #nosec G204 - r.path and ref are controlled by us
cmd := exec.CommandContext(ctx, "git", "-C", r.path, "cat-file", "-e", ref)
err := cmd.Run()
return err == nil
}
41 changes: 41 additions & 0 deletions internal/gitclone/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gitclone //nolint:testpackage // white-box testing required for unexport
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
Expand Down Expand Up @@ -214,3 +215,43 @@ func TestState_String(t *testing.T) {
assert.Equal(t, "cloning", StateCloning.String())
assert.Equal(t, "ready", StateReady.String())
}

func TestRepository_HasCommit(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
repoPath := filepath.Join(tmpDir, "test-repo")

assert.NoError(t, os.MkdirAll(repoPath, 0o755))

cmd := exec.Command("git", "-C", repoPath, "init")
assert.NoError(t, cmd.Run())

cmd = exec.Command("git", "-C", repoPath, "config", "user.email", "test@example.com")
assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", repoPath, "config", "user.name", "Test User")
assert.NoError(t, cmd.Run())

testFile := filepath.Join(repoPath, "test.txt")
assert.NoError(t, os.WriteFile(testFile, []byte("test content"), 0o644))
cmd = exec.Command("git", "-C", repoPath, "add", "test.txt")
assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", repoPath, "commit", "-m", "Initial commit")
assert.NoError(t, cmd.Run())

cmd = exec.Command("git", "-C", repoPath, "tag", "v1.0.0")
assert.NoError(t, cmd.Run())

repo := &Repository{
state: StateReady,
path: repoPath,
upstreamURL: "https://example.com/test-repo",
fetchSem: make(chan struct{}, 1),
}
repo.fetchSem <- struct{}{}

assert.True(t, repo.HasCommit(ctx, "HEAD"))
assert.True(t, repo.HasCommit(ctx, "v1.0.0"))

assert.False(t, repo.HasCommit(ctx, "nonexistent"))
assert.False(t, repo.HasCommit(ctx, "v9.9.9"))
}
2 changes: 2 additions & 0 deletions internal/strategy/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ func New(ctx context.Context, config Config, scheduler jobscheduler.Scheduler, c
return nil, errors.Wrap(err, "create clone manager")
}

gitclone.SetShared(cloneManager)

s := &Strategy{
config: config,
cache: cache,
Expand Down
73 changes: 73 additions & 0 deletions internal/strategy/gomod/fetcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package gomod

import (
"context"
"io"
"path"
"strings"
"time"

"github.com/alecthomas/errors"
"github.com/goproxy/goproxy"
)

// CompositeFetcher routes module requests to either public or private fetchers based on module path patterns.
type CompositeFetcher struct {
publicFetcher goproxy.Fetcher
privateFetcher goproxy.Fetcher
patterns []string
}

func NewCompositeFetcher(
publicFetcher goproxy.Fetcher,
privateFetcher goproxy.Fetcher,
patterns []string,
) *CompositeFetcher {
return &CompositeFetcher{
publicFetcher: publicFetcher,
privateFetcher: privateFetcher,
patterns: patterns,
}
}

func (c *CompositeFetcher) IsPrivate(modulePath string) bool {
for _, pattern := range c.patterns {
matched, err := path.Match(pattern, modulePath)
if err == nil && matched {
return true
}

if strings.HasPrefix(modulePath, pattern+"/") || modulePath == pattern {
return true
}
}

return false
}

func (c *CompositeFetcher) Query(ctx context.Context, path, query string) (version string, t time.Time, err error) {
if c.IsPrivate(path) {
v, tm, err := c.privateFetcher.Query(ctx, path, query)
return v, tm, errors.Wrap(err, "private fetcher query")
}
v, tm, err := c.publicFetcher.Query(ctx, path, query)
return v, tm, errors.Wrap(err, "public fetcher query")
}

func (c *CompositeFetcher) List(ctx context.Context, path string) (versions []string, err error) {
if c.IsPrivate(path) {
v, err := c.privateFetcher.List(ctx, path)
return v, errors.Wrap(err, "private fetcher list")
}
v, err := c.publicFetcher.List(ctx, path)
return v, errors.Wrap(err, "public fetcher list")
}

func (c *CompositeFetcher) Download(ctx context.Context, path, version string) (info, mod, zip io.ReadSeekCloser, err error) {
if c.IsPrivate(path) {
i, m, z, err := c.privateFetcher.Download(ctx, path, version)
return i, m, z, errors.Wrap(err, "private fetcher download")
}
i, m, z, err := c.publicFetcher.Download(ctx, path, version)
return i, m, z, errors.Wrap(err, "public fetcher download")
}
123 changes: 123 additions & 0 deletions internal/strategy/gomod/fetcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package gomod_test

import (
"testing"

"github.com/block/cachew/internal/strategy/gomod"
)

func TestCompositeFetcher_isPrivate(t *testing.T) {
tests := []struct {
name string
patterns []string
modulePath string
want bool
}{
{
name: "exact match single pattern",
patterns: []string{"github.com/squareup"},
modulePath: "github.com/squareup",
want: true,
},
{
name: "exact match with multiple patterns",
patterns: []string{"github.com/org1", "github.com/squareup", "github.com/org2"},
modulePath: "github.com/squareup",
want: true,
},
{
name: "prefix match - one level deep",
patterns: []string{"github.com/squareup"},
modulePath: "github.com/squareup/repo",
want: true,
},
{
name: "prefix match - two levels deep",
patterns: []string{"github.com/squareup"},
modulePath: "github.com/squareup/repo/submodule",
want: true,
},
{
name: "prefix match with multiple patterns",
patterns: []string{"github.com/org1", "github.com/squareup"},
modulePath: "github.com/squareup/repo",
want: true,
},
{
name: "wildcard match",
patterns: []string{"github.com/squareup/*"},
modulePath: "github.com/squareup/repo",
want: true,
},
{
name: "wildcard match - multiple levels",
patterns: []string{"github.com/*/*"},
modulePath: "github.com/squareup/repo",
want: true,
},
{
name: "no match - different org",
patterns: []string{"github.com/squareup"},
modulePath: "github.com/other/repo",
want: false,
},
{
name: "no match - different host",
patterns: []string{"github.com/squareup"},
modulePath: "gitlab.com/squareup/repo",
want: false,
},
{
name: "no match - prefix without slash",
patterns: []string{"github.com/square"},
modulePath: "github.com/squareup/repo",
want: false,
},
{
name: "no match - empty patterns",
patterns: []string{},
modulePath: "github.com/squareup/repo",
want: false,
},
{
name: "empty module path",
patterns: []string{"github.com/squareup"},
modulePath: "",
want: false,
},
{
name: "multiple patterns with no match",
patterns: []string{"github.com/org1", "github.com/org2", "github.com/org3"},
modulePath: "github.com/squareup/repo",
want: false,
},
{
name: "pattern with trailing slash",
patterns: []string{"github.com/squareup/"},
modulePath: "github.com/squareup/repo",
want: false,
},
{
name: "gopkg.in pattern",
patterns: []string{"gopkg.in/square"},
modulePath: "gopkg.in/square/go-jose.v2",
want: true,
},
{
name: "nested GitHub org pattern",
patterns: []string{"github.com/squareup/internal"},
modulePath: "github.com/squareup/internal/auth",
want: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fetcher := gomod.NewCompositeFetcher(nil, nil, tt.patterns)
got := fetcher.IsPrivate(tt.modulePath)
if got != tt.want {
t.Errorf("IsPrivate() = %v, want %v", got, tt.want)
}
})
}
}
48 changes: 35 additions & 13 deletions internal/strategy/gomod/gomod.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"net/http"
"net/url"

"github.com/alecthomas/errors"
"github.com/goproxy/goproxy"

"github.com/block/cachew/internal/cache"
"github.com/block/cachew/internal/gitclone"
"github.com/block/cachew/internal/logging"
"github.com/block/cachew/internal/strategy"
)
Expand All @@ -19,15 +21,17 @@ func Register(r *strategy.Registry) {
}

type Config struct {
Proxy string `hcl:"proxy,optional" help:"Upstream Go module proxy URL (defaults to proxy.golang.org)" default:"https://proxy.golang.org"`
Proxy string `hcl:"proxy,optional" help:"Upstream Go module proxy URL (defaults to proxy.golang.org)" default:"https://proxy.golang.org"`
PrivatePaths []string `hcl:"private-paths,optional" help:"Module path patterns for private repositories"`
}

type Strategy struct {
config Config
cache cache.Cache
logger *slog.Logger
proxy *url.URL
goproxy *goproxy.Goproxy
config Config
cache cache.Cache
logger *slog.Logger
proxy *url.URL
goproxy *goproxy.Goproxy
cloneManager *gitclone.Manager
}

var _ strategy.Strategy = (*Strategy)(nil)
Expand All @@ -45,14 +49,32 @@ func New(ctx context.Context, config Config, cache cache.Cache, mux strategy.Mux
proxy: parsedURL,
}

s.goproxy = &goproxy.Goproxy{
Logger: s.logger,
Fetcher: &goproxy.GoFetcher{
Env: []string{
"GOPROXY=" + config.Proxy,
"GOSUMDB=off", // Disable checksum database validation in fetcher, to prevent unneccessary double validation
},
publicFetcher := &goproxy.GoFetcher{
Env: []string{
"GOPROXY=" + config.Proxy,
"GOSUMDB=off", // Disable checksum database validation in fetcher, to prevent unneccessary double validation
},
}

var fetcher goproxy.Fetcher = publicFetcher

if len(config.PrivatePaths) > 0 {
cloneManager := gitclone.GetShared()
if cloneManager == nil {
return nil, errors.New("private-paths configured but git strategy not initialized - git strategy with mirror-root is required for private module support")
}

s.cloneManager = cloneManager
privateFetcher := newPrivateFetcher(s.logger, cloneManager)
fetcher = NewCompositeFetcher(publicFetcher, privateFetcher, config.PrivatePaths)

s.logger.InfoContext(ctx, "Configured private module support",
slog.Any("private-paths", config.PrivatePaths))
}

s.goproxy = &goproxy.Goproxy{
Logger: s.logger,
Fetcher: fetcher,
Cacher: &goproxyCacher{
cache: cache,
},
Expand Down
Loading