From c02cfc87cbf6fc515dcd4872984e8b2f909f0e4b Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Tue, 24 Feb 2026 13:23:05 +1100 Subject: [PATCH] fix: add zstd to Docker image and validate required binaries at startup Add zstd to the Alpine package list in the Dockerfile so snapshot operations work out of the box. Validate that required binaries (git, tar, zstd) are present at startup with clear error messages, rather than failing at runtime. --- docker/Dockerfile | 8 ++-- docker/Justfile | 6 ++- internal/strategy/git/git.go | 12 +++++ internal/strategy/git/git_test.go | 68 +++++++++++++++++++++++++++ internal/strategy/gomod/gomod.go | 8 ++++ internal/strategy/gomod/gomod_test.go | 40 ++++++++++++++++ 6 files changed, 136 insertions(+), 6 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 90ce866..dfe25cb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 @@ -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 diff --git a/docker/Justfile b/docker/Justfile index 5c73757..a5100b0 100644 --- a/docker/Justfile +++ b/docker/Justfile @@ -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 "."` @@ -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 diff --git a/internal/strategy/git/git.go b/internal/strategy/git/git.go index 7bd38f5..a53d977 100644 --- a/internal/strategy/git/git.go +++ b/internal/strategy/git/git.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httputil" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -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 diff --git a/internal/strategy/git/git_test.go b/internal/strategy/git/git_test.go index 02f6587..fb64fa7 100644 --- a/internal/strategy/git/git_test.go +++ b/internal/strategy/git/git_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "testing" "github.com/alecthomas/assert/v2" @@ -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 diff --git a/internal/strategy/gomod/gomod.go b/internal/strategy/gomod/gomod.go index 0a07d14..08332c6 100644 --- a/internal/strategy/gomod/gomod.go +++ b/internal/strategy/gomod/gomod.go @@ -2,10 +2,12 @@ package gomod import ( "context" + "errors" "fmt" "log/slog" "net/http" "net/url" + "os/exec" "github.com/goproxy/goproxy" @@ -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) diff --git a/internal/strategy/gomod/gomod_test.go b/internal/strategy/gomod/gomod_test.go index d50edb7..1f3a3d7 100644 --- a/internal/strategy/gomod/gomod_test.go +++ b/internal/strategy/gomod/gomod_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "runtime" "strings" "sync" "testing" @@ -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)