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: 4 additions & 4 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ ARG TARGETARCH
SHELL ["/bin/sh", "-o", "pipefail", "-c"]

# Install runtime dependencies for git operations and TLS
RUN apk add --no-cache ca-certificates git git-daemon tzdata && \
addgroup -g 1000 cachew && \
adduser -D -u 1000 -G cachew cachew
RUN apk add --no-cache ca-certificates git git-daemon tzdata zstd && \
addgroup -g 1000 cachew && \
adduser -D -u 1000 -G cachew cachew

# Set working directory (config uses relative paths like ./state/cache)
WORKDIR /app
Expand All @@ -21,7 +21,7 @@ COPY cachew.hcl /app/cachew.hcl

# Create state directory with proper permissions
RUN mkdir -p /app/state/cache && \
chown -R cachew:cachew /app
chown -R cachew:cachew /app

# Switch to non-root user
USER cachew
Expand Down
6 changes: 4 additions & 2 deletions docker/Justfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
set positional-arguments := true
set shell := ["bash", "-c"]

_help:
@just -l

# Configuration

ROOT := `git rev-parse --show-toplevel 2>/dev/null || echo "."`
Expand Down Expand Up @@ -39,8 +42,7 @@ build-multi:
@echo "✓ Built multi-arch image (in cache)"

# Run in Docker (usage: just docker run [log_level])
run log_level="info":
@just build
run log_level="info": build
@echo "→ Starting cachew at http://localhost:8080 (log-level={{ log_level }})"
@docker run --rm -it -p 8080:8080 -e CACHEW_LOG_LEVEL={{ log_level }} -v {{ ROOT }}/state:/app/state --name cachew cachew:local

Expand Down
12 changes: 12 additions & 0 deletions internal/strategy/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"net/http/httputil"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
Expand Down Expand Up @@ -59,6 +60,17 @@ func New(
cloneManagerProvider gitclone.ManagerProvider,
tokenManagerProvider githubapp.TokenManagerProvider,
) (*Strategy, error) {
if _, err := exec.LookPath("git"); err != nil {
return nil, errors.New("git is required but not found in PATH")
}
if config.SnapshotInterval > 0 {
for _, bin := range []string{"tar", "zstd"} {
if _, err := exec.LookPath(bin); err != nil {
return nil, errors.Errorf("%s is required for snapshots (snapshot-interval > 0) but not found in PATH", bin)
}
}
}

logger := logging.FromContext(ctx)

// Get GitHub App token manager if configured
Expand Down
68 changes: 68 additions & 0 deletions internal/strategy/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/alecthomas/assert/v2"
Expand Down Expand Up @@ -190,6 +191,73 @@ func TestIntegrationWithMockUpstream(t *testing.T) {
assert.NotZero(t, mux.handlers["POST /git/{host}/{path...}"])
}

// fakeBin creates a minimal executable file in dir with the given name.
func fakeBin(t *testing.T, dir, name string) {
t.Helper()
err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o755)
assert.NoError(t, err)
}

// TestNewMissingGitBinary verifies that New returns an error when git is not in PATH.
func TestNewMissingGitBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PATH manipulation for binary checks not supported on Windows")
}
_, ctx := logging.Configure(context.Background(), logging.Config{})
tmpDir := t.TempDir()
t.Setenv("PATH", t.TempDir())

mux := newTestMux()
cm := gitclone.NewManagerProvider(ctx, gitclone.Config{
MirrorRoot: filepath.Join(tmpDir, "clones"),
FetchInterval: 15,
}, nil)
_, err := git.New(ctx, git.Config{}, newTestScheduler(ctx, t), nil, mux, cm, func() (*githubapp.TokenManager, error) { return nil, nil }) //nolint:nilnil
assert.Error(t, err)
assert.Contains(t, err.Error(), "git")
}

// TestNewMissingSnapshotBinaries verifies that New returns an error when tar or zstd are
// not in PATH and snapshot-interval > 0.
func TestNewMissingSnapshotBinaries(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PATH manipulation for binary checks not supported on Windows")
}
_, ctx := logging.Configure(context.Background(), logging.Config{})
tmpDir := t.TempDir()

t.Run("MissingTar", func(t *testing.T) {
binDir := t.TempDir()
fakeBin(t, binDir, "git")
t.Setenv("PATH", binDir)

mux := newTestMux()
cm := gitclone.NewManagerProvider(ctx, gitclone.Config{
MirrorRoot: filepath.Join(tmpDir, "clones-missing-tar"),
FetchInterval: 15,
}, nil)
_, err := git.New(ctx, git.Config{SnapshotInterval: 1}, newTestScheduler(ctx, t), nil, mux, cm, func() (*githubapp.TokenManager, error) { return nil, nil }) //nolint:nilnil
assert.Error(t, err)
assert.Contains(t, err.Error(), "tar")
})

t.Run("MissingZstd", func(t *testing.T) {
binDir := t.TempDir()
fakeBin(t, binDir, "git")
fakeBin(t, binDir, "tar")
t.Setenv("PATH", binDir)

mux := newTestMux()
cm := gitclone.NewManagerProvider(ctx, gitclone.Config{
MirrorRoot: filepath.Join(tmpDir, "clones-missing-zstd"),
FetchInterval: 15,
}, nil)
_, err := git.New(ctx, git.Config{SnapshotInterval: 1}, newTestScheduler(ctx, t), nil, mux, cm, func() (*githubapp.TokenManager, error) { return nil, nil }) //nolint:nilnil
assert.Error(t, err)
assert.Contains(t, err.Error(), "zstd")
})
}

func TestParseGitRefs(t *testing.T) {
_, ctx := logging.Configure(context.Background(), logging.Config{})
_ = ctx
Expand Down
8 changes: 8 additions & 0 deletions internal/strategy/gomod/gomod.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package gomod

import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os/exec"

"github.com/goproxy/goproxy"

Expand Down Expand Up @@ -38,6 +40,12 @@ type Strategy struct {
var _ strategy.Strategy = (*Strategy)(nil)

func New(ctx context.Context, config Config, cache cache.Cache, mux strategy.Mux, cloneManagerProvider gitclone.ManagerProvider) (*Strategy, error) {
if len(config.PrivatePaths) > 0 {
if _, err := exec.LookPath("git"); err != nil {
return nil, errors.New("git is required for private module support but not found in PATH")
}
}

parsedURL, err := url.Parse(config.Proxy)
if err != nil {
return nil, fmt.Errorf("invalid proxy URL: %w", err)
Expand Down
40 changes: 40 additions & 0 deletions internal/strategy/gomod/gomod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -371,6 +372,45 @@ func TestGoModListNotCached(t *testing.T) {
assert.Equal(t, 2, mock.getRequestCount(upstreamPath), "/@v/list endpoint should not be cached")
}

// TestNewMissingGitBinaryForPrivatePaths verifies that New returns an error when git is not
// in PATH and private-paths are configured, but succeeds when no private paths are set.
func TestNewMissingGitBinaryForPrivatePaths(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PATH manipulation for binary checks not supported on Windows")
}
_, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError})

memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: 24 * time.Hour})
assert.NoError(t, err)
t.Cleanup(func() { _ = memCache.Close() })

cm := gitclone.NewManagerProvider(ctx, gitclone.Config{MirrorRoot: t.TempDir()}, nil)

t.Run("PrivatePathsRequireGit", func(t *testing.T) {
t.Setenv("PATH", t.TempDir())

mux := http.NewServeMux()
_, err := gomod.New(ctx, gomod.Config{
Proxy: "https://proxy.golang.org",
PrivatePaths: []string{"github.com/myorg"},
}, memCache, mux, cm)
assert.Error(t, err)
assert.Contains(t, err.Error(), "git")
})

t.Run("NoPrivatePathsDoNotRequireGit", func(t *testing.T) {
mock := newMockGoModServer(t)
t.Cleanup(mock.close)
t.Setenv("PATH", t.TempDir())

mux := http.NewServeMux()
_, err := gomod.New(ctx, gomod.Config{
Proxy: mock.server.URL,
}, memCache, mux, cm)
assert.NoError(t, err)
})
}

func TestGoModLatestNotCached(t *testing.T) {
mock, mux, ctx := setupGoModTest(t)

Expand Down