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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ pluginManagement {

### Gradle HTTP Build Cache

Configure in `settings.gradle(.kts)`:
Configure in `settings.gradle.kts`:

```kotlin
buildCache {
Expand All @@ -311,11 +311,13 @@ buildCache {
}
remote<HttpBuildCache> {
url = uri("http://localhost:8080/gradle/")
push = true
isPush = System.getenv("CI") != null
}
}
```

The proxy rejects uploads unless `gradle.build_cache.read_only` is set to `false`, see [docs/configuration.md](docs/configuration.md#gradle-build-cache).

### NuGet

Configure in `nuget.config`:
Expand Down
4 changes: 2 additions & 2 deletions cmd/proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
// PROXY_LOG_FORMAT - Log format
// PROXY_ACCESS_LOG_PATH - JSONL access log path
// PROXY_UPSTREAM_* - Upstream URLs and network access controls
// PROXY_GRADLE_BUILD_CACHE_READ_ONLY - Disable Gradle PUT uploads
// PROXY_GRADLE_BUILD_CACHE_READ_ONLY - Set false to allow Gradle PUT uploads (default true)
// PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE - Max Gradle PUT request body size
// PROXY_GRADLE_BUILD_CACHE_MAX_AGE - Gradle cache max age eviction
// PROXY_GRADLE_BUILD_CACHE_MAX_SIZE - Gradle cache max total size
Expand Down Expand Up @@ -257,7 +257,7 @@ func runServe() {
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_OCI_DEFAULT Default OCI registry upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_DEBIAN Debian repository upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_RPM RPM repository upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_READ_ONLY Disable Gradle PUT uploads\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_READ_ONLY Set false to allow Gradle PUT uploads (default true)\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE Max Gradle PUT request body size\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_AGE Gradle cache max age eviction\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_SIZE Gradle cache max total size\n")
Expand Down
5 changes: 3 additions & 2 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,9 @@ upstream:
# Gradle HttpBuildCache configuration
gradle:
build_cache:
# Set to true to disable PUT uploads (read-only cache mode)
read_only: false
# PUT uploads are rejected by default; set to false on the instance
# your CI jobs upload to
read_only: true

# Maximum accepted Gradle cache upload body size
# Required and must be > 0
Expand Down
6 changes: 4 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,7 @@ The `/gradle` endpoint supports optional safeguards for upload control and cache
```yaml
gradle:
build_cache:
read_only: false
read_only: true
max_upload_size: "100MB"
max_age: "168h"
max_size: "20GB"
Expand All @@ -683,12 +683,14 @@ gradle:

| Config | Environment | Description |
|--------|-------------|-------------|
| `gradle.build_cache.read_only` | `PROXY_GRADLE_BUILD_CACHE_READ_ONLY` | Disable PUT uploads and keep GET/HEAD read-only |
| `gradle.build_cache.read_only` | `PROXY_GRADLE_BUILD_CACHE_READ_ONLY` | Reject PUT uploads and serve GET/HEAD only (default `true`; the variable takes `true`/`false` or `1`/`0`, any other value keeps uploads off and logs a warning) |
| `gradle.build_cache.max_upload_size` | `PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE` | Maximum accepted PUT body size (must be > 0) |
| `gradle.build_cache.max_age` | `PROXY_GRADLE_BUILD_CACHE_MAX_AGE` | Delete entries older than this duration (default `168h`, also accepts days such as `7d`; set `0` to disable) |
| `gradle.build_cache.max_size` | `PROXY_GRADLE_BUILD_CACHE_MAX_SIZE` | Total size cap for `_gradle/http-build-cache`, deleting oldest first (`0` disables) |
| `gradle.build_cache.sweep_interval` | `PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL` | Frequency for background eviction sweeps |

Uploads are off by default, so a client that can reach the proxy cannot overwrite cache entries that end up in other people's builds. Set `read_only: false` (or `PROXY_GRADLE_BUILD_CACHE_READ_ONLY=false`) on the instance your CI jobs upload to, and let only CI push (`isPush = System.getenv("CI") != null`). Before this default changed, uploads were accepted without configuration; setups that upload need the setting now.

`max_age` and `max_size` are independent and can be combined. When both are set, age-based eviction runs first, then size-based eviction trims remaining entries oldest-first.

## Cooldown
Expand Down
30 changes: 28 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ type Config struct {

// Health configures the /health endpoint behavior.
Health HealthConfig `json:"health" yaml:"health"`

// envWarnings collects environment values LoadFromEnv ignored.
envWarnings []string
}

// CooldownConfig configures version cooldown periods.
Expand Down Expand Up @@ -463,6 +466,7 @@ type GradleConfig struct {
// GradleBuildCacheConfig configures Gradle HttpBuildCache safeguards.
type GradleBuildCacheConfig struct {
// ReadOnly disables PUT uploads and keeps cache reads (GET/HEAD) enabled.
// Default: true; set false on the instance that CI uploads to.
ReadOnly bool `json:"read_only" yaml:"read_only"`

// MaxUploadSize caps a single PUT body size (e.g., "100MB"). Must be > 0.
Expand Down Expand Up @@ -1064,7 +1068,7 @@ func Default() *Config {
},
Gradle: GradleConfig{
BuildCache: GradleBuildCacheConfig{
ReadOnly: false,
ReadOnly: true,
MaxUploadSize: defaultGradleMaxUploadSizeStr,
MaxAge: "168h",
MaxSize: "",
Expand Down Expand Up @@ -1120,6 +1124,28 @@ func setEnvBool(dst *bool, key string) {
}
}

// setGradleReadOnlyFromEnv parses strictly: an unrecognised value keeps
// uploads off instead of opening them, and is reported via EnvWarnings.
func (c *Config) setGradleReadOnlyFromEnv() {
const key = "PROXY_GRADLE_BUILD_CACHE_READ_ONLY"
v := os.Getenv(key)
if v == "" {
return
}
readOnly, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
c.Gradle.BuildCache.ReadOnly = true
c.envWarnings = append(c.envWarnings, fmt.Sprintf("ignoring %s=%q: not a boolean, keeping the Gradle build cache read-only", key, v))
return
}
c.Gradle.BuildCache.ReadOnly = readOnly
}

// EnvWarnings returns the environment values LoadFromEnv ignored.
func (c *Config) EnvWarnings() []string {
return c.envWarnings
}

func setEnvStringSlice(dst *[]string, key string) {
value := os.Getenv(key)
if value == "" {
Expand Down Expand Up @@ -1222,7 +1248,7 @@ func (c *Config) LoadFromEnv() {
setEnvString(&c.ArtifactMaxSize, "PROXY_ARTIFACT_MAX_SIZE")
setEnvString(&c.MetadataRewriteCacheSize, "PROXY_METADATA_REWRITE_CACHE_SIZE")
setEnvString(&c.HTTPTimeout, "PROXY_HTTP_TIMEOUT")
setEnvBool(&c.Gradle.BuildCache.ReadOnly, "PROXY_GRADLE_BUILD_CACHE_READ_ONLY")
c.setGradleReadOnlyFromEnv()
setEnvString(&c.Gradle.BuildCache.MaxUploadSize, "PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE")
setEnvString(&c.Gradle.BuildCache.MaxAge, "PROXY_GRADLE_BUILD_CACHE_MAX_AGE")
setEnvString(&c.Gradle.BuildCache.MaxSize, "PROXY_GRADLE_BUILD_CACHE_MAX_SIZE")
Expand Down
67 changes: 64 additions & 3 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ func TestDefault(t *testing.T) {
if cfg.Gradle.BuildCache.MaxAge != "168h" {
t.Errorf("Gradle.BuildCache.MaxAge = %q, want %q", cfg.Gradle.BuildCache.MaxAge, "168h")
}
if !cfg.Gradle.BuildCache.ReadOnly {
t.Error("Gradle.BuildCache.ReadOnly = false, want true by default")
}
if len(cfg.Upstream.AllowPrivateHosts) != 0 {
t.Errorf("Upstream.AllowPrivateHosts = %v, want empty", cfg.Upstream.AllowPrivateHosts)
}
Expand Down Expand Up @@ -417,6 +420,64 @@ upstream:
}
}

func TestLoadGradleBuildCacheReadOnly(t *testing.T) {
tests := []struct {
name string
content string
want bool
}{
{"block without read_only keeps default", "gradle:\n build_cache:\n max_age: \"12h\"\n", true},
{"explicit false allows uploads", "gradle:\n build_cache:\n read_only: false\n", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
t.Fatalf("writing config file: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Gradle.BuildCache.ReadOnly != tt.want {
t.Errorf("Gradle.BuildCache.ReadOnly = %v, want %v", cfg.Gradle.BuildCache.ReadOnly, tt.want)
}
})
}
}

func TestLoadGradleBuildCacheReadOnlyFromEnv(t *testing.T) {
tests := []struct {
value string
fileRO bool
want bool
warnings int
}{
{"false", true, false, 0},
{"0", true, false, 0},
{" FALSE ", true, false, 0},
{"True", false, true, 0},
{"1", false, true, 0},
{"yes", false, true, 1},
{"off", false, true, 1},
{"", false, false, 0},
}
for _, tt := range tests {
t.Run(tt.value, func(t *testing.T) {
t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", tt.value)
cfg := Default()
cfg.Gradle.BuildCache.ReadOnly = tt.fileRO
cfg.LoadFromEnv()
if cfg.Gradle.BuildCache.ReadOnly != tt.want {
t.Errorf("Gradle.BuildCache.ReadOnly = %v, want %v", cfg.Gradle.BuildCache.ReadOnly, tt.want)
}
if got := len(cfg.EnvWarnings()); got != tt.warnings {
t.Errorf("len(EnvWarnings()) = %d, want %d: %v", got, tt.warnings, cfg.EnvWarnings())
}
})
}
}

func TestLoadJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
Expand Down Expand Up @@ -466,7 +527,7 @@ func TestLoadFromEnv(t *testing.T) {
t.Setenv("PROXY_UI_FAILURE_DETAILS", "true")
t.Setenv("PROXY_UPSTREAM_ALLOW_PRIVATE_HOSTS", "registry.internal, 10.0.0.12")
t.Setenv("PROXY_UPSTREAM_ALLOW_LOOPBACK", "true")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", "true")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", "false")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE", "12h")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_SIZE", "10GB")
Expand Down Expand Up @@ -517,8 +578,8 @@ func TestLoadFromEnv(t *testing.T) {
t.Errorf("Upstream.AllowPrivateHosts after empty env = %q, want unchanged", got)
}

if !cfg.Gradle.BuildCache.ReadOnly {
t.Error("Gradle.BuildCache.ReadOnly = false, want true")
if cfg.Gradle.BuildCache.ReadOnly {
t.Error("Gradle.BuildCache.ReadOnly = true, want false from the environment")
}
if cfg.Gradle.BuildCache.MaxUploadSize != "32MB" {
t.Errorf("Gradle.BuildCache.MaxUploadSize = %q, want %q", cfg.Gradle.BuildCache.MaxUploadSize, "32MB")
Expand Down
7 changes: 4 additions & 3 deletions internal/server/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,13 +359,14 @@ index-url = ` + baseURL + `/pypi/simple/</code></pre>`),
Language: "Java/Kotlin",
Endpoint: "/gradle/",
Instructions: template.HTML(`<p class="config-note">Configure Gradle to use the proxy for HttpBuildCache:</p>
<pre><code>// In settings.gradle(.kts)
<pre><code>// In settings.gradle.kts
buildCache {
remote&lt;HttpBuildCache&gt; {
url = uri("` + baseURL + `/gradle/")
push = true
isPush = System.getenv("CI") != null
}
}</code></pre>`),
}</code></pre>
<p class="config-note">Uploads need <code>gradle.build_cache.read_only: false</code> on the proxy.</p>`),
},
{
ID: "nuget",
Expand Down
81 changes: 81 additions & 0 deletions internal/server/gradle_read_only_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package server

import (
"context"
"io"
"log/slog"
"net"
"net/http"
"path/filepath"
"strings"
"testing"
"time"

"github.com/git-pkgs/proxy/internal/config"
)

func startGradleServer(t *testing.T, readOnly bool) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserving proxy address: %v", err)
}
t.Cleanup(func() { _ = listener.Close() })

tempDir := t.TempDir()
cfg := config.Default()
cfg.Listen = listener.Addr().String()
cfg.BaseURL = "http://" + cfg.Listen
cfg.Database.Path = filepath.Join(tempDir, "proxy.db")
cfg.Storage.URL = "file://" + filepath.Join(tempDir, "artifacts")
cfg.Gradle.BuildCache.ReadOnly = readOnly
if err := cfg.Validate(); err != nil {
t.Fatalf("validating config: %v", err)
}

proxyServer, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), BuildInfo{Version: "test", Commit: "test"})
if err != nil {
t.Fatalf("creating server: %v", err)
}
startErr := make(chan error, 1)
go func() { startErr <- proxyServer.Start(listener) }()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = proxyServer.Shutdown(ctx)
<-startErr
})
return cfg.BaseURL
}

func gradleRequest(t *testing.T, method, url, body string) int {
t.Helper()
req, err := http.NewRequest(method, url, strings.NewReader(body))
if err != nil {
t.Fatalf("building request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
_ = resp.Body.Close()
return resp.StatusCode
}

func TestGradleBuildCacheReadOnlyFollowsConfig(t *testing.T) {
readOnly := startGradleServer(t, config.Default().Gradle.BuildCache.ReadOnly)
if got := gradleRequest(t, http.MethodPut, readOnly+"/gradle/abc123", "entry"); got != http.StatusMethodNotAllowed {
t.Errorf("PUT with default config = %d, want %d", got, http.StatusMethodNotAllowed)
}
if got := gradleRequest(t, http.MethodGet, readOnly+"/gradle/abc123", ""); got != http.StatusNotFound {
t.Errorf("GET after rejected PUT = %d, want %d", got, http.StatusNotFound)
}

writable := startGradleServer(t, false)
if got := gradleRequest(t, http.MethodPut, writable+"/gradle/abc123", "entry"); got != http.StatusCreated {
t.Errorf("PUT with read_only false = %d, want %d", got, http.StatusCreated)
}
if got := gradleRequest(t, http.MethodGet, writable+"/gradle/abc123", ""); got != http.StatusOK {
t.Errorf("GET after PUT = %d, want %d", got, http.StatusOK)
}
}
3 changes: 3 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,9 @@ func (s *Server) serve(listener net.Listener) error {
"ui_url", s.cfg.UIBaseURL,
"storage", s.storage.URL(),
"database", s.cfg.Database.String())
for _, warning := range s.cfg.EnvWarnings() {
s.logger.Warn(warning)
}
go s.updateCacheStatsMetrics()
go s.startEvictionLoop(bgCtx)
go s.startRetentionLoop(bgCtx)
Expand Down
Loading