diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 82ee83c..e0d3cfb 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -38,7 +38,7 @@ jobs:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker image
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f
with:
context: .
push: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c23de56..c5f2ebf 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ jobs:
fetch-depth: 0
persist-credentials: false
- - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+ - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -27,7 +27,7 @@ jobs:
go-version-file: go.mod
cache: false
- - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
+ - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1
with:
version: "~> v2"
args: release --clean
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
index f621254..4acd19b 100644
--- a/.github/workflows/zizmor.yml
+++ b/.github/workflows/zizmor.yml
@@ -26,4 +26,4 @@ jobs:
persist-credentials: false
- name: Run zizmor
- uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
+ uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
diff --git a/Dockerfile b/Dockerfile
index 5124b1d..71a9fcc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.26.3-alpine AS builder
+FROM golang:1.25-alpine AS builder
WORKDIR /src
@@ -15,7 +15,7 @@ COPY . .
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /proxy ./cmd/proxy
-FROM alpine:3.23.4
+FROM alpine:3.21
RUN apk add --no-cache ca-certificates
diff --git a/README.md b/README.md
index abf3e11..792c76b 100644
--- a/README.md
+++ b/README.md
@@ -210,18 +210,6 @@ Add to your `~/.m2/settings.xml`:
```
-The `/maven/` endpoint uses Maven Central as primary upstream and falls back to the Gradle Plugin Portal for Gradle plugin marker metadata and related artifacts when the primary upstream returns not found.
-
-For Gradle plugin resolution via the same proxy endpoint:
-
-```kotlin
-pluginManagement {
- repositories {
- maven(url = "http://localhost:8080/maven/")
- }
-}
-```
-
### Gradle HTTP Build Cache
Configure in `settings.gradle(.kts)`:
@@ -398,7 +386,6 @@ sudo dnf update
## Configuration
The proxy can be configured via:
-
1. Command line flags (highest priority)
2. Environment variables
3. Configuration file (YAML or JSON)
@@ -606,7 +593,7 @@ Recently cached:
| Endpoint | Description |
|----------|-------------|
| `GET /` | Dashboard (web UI) |
-| `GET /health` | Health check (JSON; HTTP 200 healthy, 503 unhealthy) |
+| `GET /health` | Health check (returns "ok" if healthy) |
| `GET /stats` | Cache statistics (JSON) |
| `GET /metrics` | Prometheus metrics |
| `GET /npm/*` | npm registry protocol |
@@ -845,28 +832,9 @@ The proxy exposes Prometheus metrics at `GET /metrics`. All metric names are pre
| `proxy_storage_operation_duration_seconds` | histogram | `operation` | Storage read/write latency |
| `proxy_storage_errors_total` | counter | `operation` | Storage read/write failures |
| `proxy_active_requests` | gauge | | In-flight requests |
-| `proxy_health_probe_failures_total` | counter | `step` | Storage health probe failures by failing step (`write`, `size`, `read`, `verify`, `delete`). |
Cache size and artifact count are refreshed every 60 seconds. The remaining metrics update on each request.
-### Health Check
-
-`/health` returns a structured JSON report of subsystem health. HTTP 200 if all checks pass; 503 if any fail.
-
-```json
-{
- "status": "ok",
- "checks": {
- "database": {"status": "ok"},
- "storage": {"status": "ok"}
- }
-}
-```
-
-Failing checks include an `"error"` field. Storage failures also include a `"step"` field identifying which probe step failed (`write`, `size`, `read`, `verify`, `delete`). When the database check fails, the storage entry reports `{"status": "skipped"}` so the response always carries the same key set.
-
-Storage probe results are cached for `health.storage_probe_interval` (default 30s) to bound the cost of probing remote backends. A probe holds an internal mutex for up to 10 seconds (the hardcoded per-probe timeout), so `/health` is intended as a Kubernetes **readiness** probe rather than a liveness probe — a slow S3 round-trip should pull the pod from rotation, not restart it.
-
Scrape config for Prometheus:
```yaml
@@ -990,7 +958,6 @@ The proxy will recreate the database on next start.
## Building from Source
Requirements:
-
- Go 1.25 or later
```bash
diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go
index 15a71c0..946d12a 100644
--- a/cmd/proxy/main.go
+++ b/cmd/proxy/main.go
@@ -72,14 +72,11 @@
// PROXY_DATABASE_URL - PostgreSQL connection URL
// PROXY_LOG_LEVEL - Log level
// PROXY_LOG_FORMAT - Log format
-// PROXY_UPSTREAM_MAVEN - Maven repository upstream URL
-// PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL - Gradle Plugin Portal upstream URL
// PROXY_GRADLE_BUILD_CACHE_READ_ONLY - Disable Gradle PUT uploads
// 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
// PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL - Gradle cache eviction sweep interval
-// PROXY_HEALTH_STORAGE_PROBE_INTERVAL - Storage health probe cache interval (default "30s")
//
// Example:
//
@@ -201,14 +198,11 @@ func runServe() {
fmt.Fprintf(os.Stderr, " PROXY_DATABASE_URL PostgreSQL connection URL\n")
fmt.Fprintf(os.Stderr, " PROXY_LOG_LEVEL Log level\n")
fmt.Fprintf(os.Stderr, " PROXY_LOG_FORMAT Log format\n")
- fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_MAVEN Maven repository upstream URL\n")
- fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL Gradle Plugin Portal 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_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")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL Gradle cache eviction sweep interval\n")
- fmt.Fprintf(os.Stderr, " PROXY_HEALTH_STORAGE_PROBE_INTERVAL Storage health probe cache interval\n")
}
_ = fs.Parse(os.Args[1:])
@@ -470,7 +464,6 @@ func runMirror() {
proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
proxy.CacheMetadata = true // mirror always caches metadata
proxy.MetadataTTL = cfg.ParseMetadataTTL()
- proxy.MetadataMaxSize = cfg.ParseMetadataMaxSize()
m := mirror.New(proxy, db, store, logger, *concurrency)
diff --git a/config.example.yaml b/config.example.yaml
index 11c751c..4505849 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -71,12 +71,6 @@ upstream:
# npm registry URL
npm: "https://registry.npmjs.org"
- # Maven repository URL (used by /maven endpoint)
- maven: "https://repo1.maven.org/maven2"
-
- # Gradle Plugin Portal Maven URL (fallback for plugin marker artifacts)
- gradle_plugin_portal: "https://plugins.gradle.org/m2"
-
# Cargo sparse index URL
cargo: "https://index.crates.io"
@@ -134,15 +128,6 @@ gradle:
# How often eviction runs when max_age or max_size is set
sweep_interval: "10m"
-# Health endpoint configuration.
-health:
- # Minimum time between storage backend probes.
- # The /health endpoint runs a write/read/verify/delete round-trip
- # against the configured storage backend and caches the result for
- # this interval. Set to "0" to probe on every request.
- # Default: "30s".
- storage_probe_interval: "30s"
-
# Version cooldown configuration
# Hides package versions published too recently, giving the community time
# to spot malicious releases before they're pulled into projects.
diff --git a/docs/architecture.md b/docs/architecture.md
index 85e5aaf..f6c5458 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -277,15 +277,15 @@ HTTP server setup, web UI, and API handlers.
- Web UI: dashboard, package browser, source browser, version comparison
- Templates are embedded in the binary via `//go:embed`
- Enrichment API for package metadata, vulnerability scanning, and outdated detection
-- Health, stats, and Prometheus metrics endpoints. `/health` runs an active write → size-check → read → verify → delete probe against the storage backend and returns a structured JSON response (`HealthResponse`) with `"ok"` / `"error"` status per subsystem. Probe results are cached (default 30 s, configurable via `health.storage_probe_interval`) to avoid overwhelming remote backends.
+- Health, stats, and Prometheus metrics endpoints
### `internal/metrics`
Prometheus metrics for cache performance, upstream latency, storage operations, and active requests. See the Monitoring section of the README for the full metric list.
-### Cooldown
+### `internal/cooldown`
-Version age filtering for supply chain attack mitigation, provided by [github.com/git-pkgs/cooldown](https://github.com/git-pkgs/cooldown). Configurable at global, ecosystem, and per-package levels. Supported by npm, PyPI, pub.dev, and Composer handlers.
+Version age filtering for supply chain attack mitigation. Configurable at global, ecosystem, and per-package levels. Supported by npm, PyPI, pub.dev, and Composer handlers.
### `internal/enrichment`
diff --git a/docs/configuration.md b/docs/configuration.md
index 1310bd0..ac85d54 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -114,8 +114,6 @@ Override default upstream registry URLs:
```yaml
upstream:
npm: "https://registry.npmjs.org"
- maven: "https://repo1.maven.org/maven2"
- gradle_plugin_portal: "https://plugins.gradle.org/m2"
cargo: "https://index.crates.io"
cargo_download: "https://static.crates.io/crates"
```
@@ -265,16 +263,6 @@ Set to `"0"` to always revalidate with upstream (ETag-based conditional requests
When upstream is unreachable and the cached entry is past its TTL, the proxy serves the stale cached copy with a `Warning: 110 - "Response is Stale"` header so clients can tell the data may be outdated.
-### Metadata size limit
-
-Upstream metadata responses are buffered in memory before being rewritten and served. `metadata_max_size` caps that buffer to protect against OOM from a misbehaving upstream. Some npm packages with thousands of versions (for example `renovate`) exceed the 100 MB default, so raise this if you see `metadata response exceeds size limit` in the logs.
-
-```yaml
-metadata_max_size: "100MB" # default
-```
-
-Or via environment variable: `PROXY_METADATA_MAX_SIZE=250MB`.
-
## Mirror API
The `/api/mirror` endpoints are disabled by default. Enable them to allow starting mirror jobs via HTTP:
diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go
index 23ff54a..34aedbf 100644
--- a/docs/swagger/docs.go
+++ b/docs/swagger/docs.go
@@ -399,7 +399,7 @@ const docTemplate = `{
"/health": {
"get": {
"produces": [
- "application/json"
+ "text/plain"
],
"tags": [
"meta"
@@ -409,13 +409,13 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/server.HealthResponse"
+ "type": "string"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
- "$ref": "#/definitions/server.HealthResponse"
+ "type": "string"
}
}
}
@@ -515,34 +515,6 @@ const docTemplate = `{
}
}
},
- "server.HealthCheck": {
- "type": "object",
- "properties": {
- "error": {
- "type": "string"
- },
- "status": {
- "type": "string"
- },
- "step": {
- "type": "string"
- }
- }
- },
- "server.HealthResponse": {
- "type": "object",
- "properties": {
- "checks": {
- "type": "object",
- "additionalProperties": {
- "$ref": "#/definitions/server.HealthCheck"
- }
- },
- "status": {
- "type": "string"
- }
- }
- },
"server.OutdatedPackage": {
"type": "object",
"properties": {
diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json
index c2b4dfc..b775e63 100644
--- a/docs/swagger/swagger.json
+++ b/docs/swagger/swagger.json
@@ -392,7 +392,7 @@
"/health": {
"get": {
"produces": [
- "application/json"
+ "text/plain"
],
"tags": [
"meta"
@@ -402,13 +402,13 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/server.HealthResponse"
+ "type": "string"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
- "$ref": "#/definitions/server.HealthResponse"
+ "type": "string"
}
}
}
@@ -508,34 +508,6 @@
}
}
},
- "server.HealthCheck": {
- "type": "object",
- "properties": {
- "error": {
- "type": "string"
- },
- "status": {
- "type": "string"
- },
- "step": {
- "type": "string"
- }
- }
- },
- "server.HealthResponse": {
- "type": "object",
- "properties": {
- "checks": {
- "type": "object",
- "additionalProperties": {
- "$ref": "#/definitions/server.HealthCheck"
- }
- },
- "status": {
- "type": "string"
- }
- }
- },
"server.OutdatedPackage": {
"type": "object",
"properties": {
diff --git a/go.mod b/go.mod
index 199c8c8..e91ab4f 100644
--- a/go.mod
+++ b/go.mod
@@ -4,14 +4,13 @@ go 1.25.6
require (
github.com/BurntSushi/toml v1.6.0
- github.com/CycloneDX/cyclonedx-go v0.11.0
+ github.com/CycloneDX/cyclonedx-go v0.10.0
github.com/git-pkgs/archives v0.3.0
- github.com/git-pkgs/cooldown v0.1.1
- github.com/git-pkgs/enrichment v0.2.3
+ github.com/git-pkgs/enrichment v0.2.2
github.com/git-pkgs/purl v0.1.12
- github.com/git-pkgs/registries v0.6.1
- github.com/git-pkgs/spdx v0.1.4
- github.com/git-pkgs/vers v0.2.6
+ github.com/git-pkgs/registries v0.5.1
+ github.com/git-pkgs/spdx v0.1.3
+ github.com/git-pkgs/vers v0.2.5
github.com/git-pkgs/vulns v0.1.5
github.com/go-chi/chi/v5 v5.2.5
github.com/jmoiron/sqlx v1.4.0
@@ -24,7 +23,7 @@ require (
golang.org/x/sync v0.20.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
- modernc.org/sqlite v1.50.1
+ modernc.org/sqlite v1.50.0
)
require (
@@ -129,7 +128,7 @@ require (
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/git-pkgs/packageurl-go v0.3.1 // indirect
github.com/git-pkgs/pom v0.1.4 // indirect
- github.com/github/go-spdx/v2 v2.7.0 // indirect
+ github.com/github/go-spdx/v2 v2.6.0 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -310,7 +309,7 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
- modernc.org/libc v1.72.3 // indirect
+ modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
diff --git a/go.sum b/go.sum
index 23c3df7..80df597 100644
--- a/go.sum
+++ b/go.sum
@@ -66,8 +66,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQ
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
-github.com/CycloneDX/cyclonedx-go v0.11.0 h1:GokP8FiRC+foiuwWhSSLpSD5H4hSWtGnR3wo7apkBFI=
-github.com/CycloneDX/cyclonedx-go v0.11.0/go.mod h1:vUvbCXQsEm48OI6oOlanxstwNByXjCZ2wuleUlwGEO8=
+github.com/CycloneDX/cyclonedx-go v0.10.0 h1:7xyklU7YD+CUyGzSFIARG18NYLsKVn4QFg04qSsu+7Y=
+github.com/CycloneDX/cyclonedx-go v0.10.0/go.mod h1:vUvbCXQsEm48OI6oOlanxstwNByXjCZ2wuleUlwGEO8=
github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g=
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
@@ -252,26 +252,24 @@ github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/git-pkgs/archives v0.3.0 h1:iXKyO83jEFub1PGEDlHmk2tQ7XeV5LySTc0sEkH3x78=
github.com/git-pkgs/archives v0.3.0/go.mod h1:LTJ1iQVFA7otizWMOyiI82NYVmyBWAPRzwu/e30rcXU=
-github.com/git-pkgs/cooldown v0.1.1 h1:9OqqzCB8gANz/y44SmqGD0Jp8Qtu81D1sCbKl6Ehg7w=
-github.com/git-pkgs/cooldown v0.1.1/go.mod h1:v7APuK/UouTiu8mWQZbdDmj7DfxxkGUeuhjaRB5gv9E=
-github.com/git-pkgs/enrichment v0.2.3 h1:42mqoUhQZNGhlEO671pboI/Cu6F+DoffJoFbVhb2jlw=
-github.com/git-pkgs/enrichment v0.2.3/go.mod h1:MBv5nhHzjwLxeSgx2+7waCcpReUjhCD+9B0bvufpMO0=
+github.com/git-pkgs/enrichment v0.2.2 h1:vaQu5vs3tjQB5JI0gzBrUCynUc9z3l5byPhgKFaNZrc=
+github.com/git-pkgs/enrichment v0.2.2/go.mod h1:5JWGmlHWcv5HQHUrctcpnRUNpEF5VAixD2z4zvqKejs=
github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6Jt5ak7M=
github.com/git-pkgs/packageurl-go v0.3.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4=
github.com/git-pkgs/pom v0.1.4 h1:C6st+XSbF75eKuwfdkDZZtYHoTcaWRIEQYar5VtszUo=
github.com/git-pkgs/pom v0.1.4/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk=
github.com/git-pkgs/purl v0.1.12 h1:qCskrEU1LWQhCkIVZd992W5++Bsxazvx2Cx1/65qCvU=
github.com/git-pkgs/purl v0.1.12/go.mod h1:ofp4mHsR0cUeVONQaf33n6Wxg2QTEvtUdRfCedI8ouA=
-github.com/git-pkgs/registries v0.6.1 h1:xZfVZQmffIfdeJthn5o2EozbVJ6gBeImYwKQnfdKUfU=
-github.com/git-pkgs/registries v0.6.1/go.mod h1:a3BP/56VW3O/CFRqiJCtSy+OqRrSH25wF1PWHP76ka0=
-github.com/git-pkgs/spdx v0.1.4 h1:eQ0waEV3uUeItpWAOvdN1K1rL9hTgsU7fF74r1mDXMs=
-github.com/git-pkgs/spdx v0.1.4/go.mod h1:cqRoZcvl530s/W+oGNvwjt4ODN8T1W6D/20MUZEFdto=
-github.com/git-pkgs/vers v0.2.6 h1:IelZd7BP/JhzTloUTDY67nehUgoYva3g9viqAMCHJg8=
-github.com/git-pkgs/vers v0.2.6/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
+github.com/git-pkgs/registries v0.5.1 h1:UPE42CyZAsOfqO3N5bDelu28wS4Ifx/aOj0XZS4qYeI=
+github.com/git-pkgs/registries v0.5.1/go.mod h1:BY0YW+V0WDGBMuDR2aSMR3NzOPFK4K+F3j6+ch+cq3M=
+github.com/git-pkgs/spdx v0.1.3 h1:YQou23mLfzbW//6JlHUuc5x1P5VNIIDSku5gvauf86I=
+github.com/git-pkgs/spdx v0.1.3/go.mod h1:4HGGWyC8tg4DjOhrtBTYl4Lu+5i2BFuauGX8zcVcYPg=
+github.com/git-pkgs/vers v0.2.5 h1:tDtUMik9Iw1lyPHdT5V6LXjLo9LsJc0xOawURz7ibQU=
+github.com/git-pkgs/vers v0.2.5/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
github.com/git-pkgs/vulns v0.1.5 h1:mtX88/27toFl+B95kaH5QbAdOCQ3YIDGjJrlrrnqQTE=
github.com/git-pkgs/vulns v0.1.5/go.mod h1:bZFikfrR/5gC0ZMwXh7qcEu2gpKfXMBhVsy4kF12Ae0=
-github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ=
-github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0=
+github.com/github/go-spdx/v2 v2.6.0 h1:Y/Chr7L8oG85Ilbzl11xkUSQFUfG1kGkLP18LyInvhg=
+github.com/github/go-spdx/v2 v2.6.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
@@ -884,10 +882,10 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
-modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
-modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
-modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
-modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
+modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
+modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
+modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
+modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -896,18 +894,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
-modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
+modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
+modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
-modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
-modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
+modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
+modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
diff --git a/internal/config/config.go b/internal/config/config.go
index 0e8405d..067981d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -96,20 +96,12 @@ type Config struct {
// Default: "5m". Set to "0" to always revalidate.
MetadataTTL string `json:"metadata_ttl" yaml:"metadata_ttl"`
- // MetadataMaxSize is the maximum size of an upstream metadata response
- // the proxy will buffer (e.g. "100MB", "250MB"). Responses over this
- // size return ErrMetadataTooLarge. Default: "100MB".
- MetadataMaxSize string `json:"metadata_max_size" yaml:"metadata_max_size"`
-
// MirrorAPI enables the /api/mirror endpoints for starting mirror jobs via HTTP.
// Disabled by default to prevent unauthenticated users from triggering downloads.
MirrorAPI bool `json:"mirror_api" yaml:"mirror_api"`
// Gradle configures Gradle HttpBuildCache behavior.
Gradle GradleConfig `json:"gradle" yaml:"gradle"`
-
- // Health configures the /health endpoint behavior.
- Health HealthConfig `json:"health" yaml:"health"`
}
// CooldownConfig configures version cooldown periods.
@@ -190,14 +182,6 @@ type GradleBuildCacheConfig struct {
SweepInterval string `json:"sweep_interval" yaml:"sweep_interval"`
}
-// HealthConfig configures the /health endpoint.
-type HealthConfig struct {
- // StorageProbeInterval is the minimum time between storage backend probes.
- // Uses Go duration syntax (e.g. "30s", "1m"). Default: "30s".
- // Set to "0" to probe on every /health request (useful for low-traffic deployments).
- StorageProbeInterval string `json:"storage_probe_interval" yaml:"storage_probe_interval"`
-}
-
// DatabaseConfig configures the cache database.
type DatabaseConfig struct {
// Driver is the database driver: "sqlite" or "postgres".
@@ -226,15 +210,6 @@ type UpstreamConfig struct {
// Default: https://registry.npmjs.org
NPM string `json:"npm" yaml:"npm"`
- // Maven is the upstream Maven repository URL.
- // Default: https://repo1.maven.org/maven2
- Maven string `json:"maven" yaml:"maven"`
-
- // GradlePluginPortal is the upstream Gradle Plugin Portal Maven URL.
- // Used to resolve Gradle plugin marker artifacts.
- // Default: https://plugins.gradle.org/m2
- GradlePluginPortal string `json:"gradle_plugin_portal" yaml:"gradle_plugin_portal"`
-
// Cargo is the upstream cargo index URL.
// Default: https://index.crates.io
Cargo string `json:"cargo" yaml:"cargo"`
@@ -312,11 +287,9 @@ func Default() *Config {
Format: "text",
},
Upstream: UpstreamConfig{
- NPM: "https://registry.npmjs.org",
- Maven: "https://repo1.maven.org/maven2",
- GradlePluginPortal: "https://plugins.gradle.org/m2",
- Cargo: "https://index.crates.io",
- CargoDownload: "https://static.crates.io/crates",
+ NPM: "https://registry.npmjs.org",
+ Cargo: "https://index.crates.io",
+ CargoDownload: "https://static.crates.io/crates",
},
Gradle: GradleConfig{
BuildCache: GradleBuildCacheConfig{
@@ -370,7 +343,6 @@ func Load(path string) (*Config, error) {
// - PROXY_DATABASE_PATH
// - PROXY_LOG_LEVEL
// - PROXY_LOG_FORMAT
-// - PROXY_HEALTH_STORAGE_PROBE_INTERVAL
func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_LISTEN"); v != "" {
c.Listen = v
@@ -411,12 +383,6 @@ func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_LOG_FORMAT"); v != "" {
c.Log.Format = v
}
- if v := os.Getenv("PROXY_UPSTREAM_MAVEN"); v != "" {
- c.Upstream.Maven = v
- }
- if v := os.Getenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL"); v != "" {
- c.Upstream.GradlePluginPortal = v
- }
if v := os.Getenv("PROXY_COOLDOWN_DEFAULT"); v != "" {
c.Cooldown.Default = v
}
@@ -429,9 +395,6 @@ func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_METADATA_TTL"); v != "" {
c.MetadataTTL = v
}
- if v := os.Getenv("PROXY_METADATA_MAX_SIZE"); v != "" {
- c.MetadataMaxSize = v
- }
if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY"); v != "" {
c.Gradle.BuildCache.ReadOnly = v == "true" || v == "1"
}
@@ -447,9 +410,6 @@ func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL"); v != "" {
c.Gradle.BuildCache.SweepInterval = v
}
- if v := os.Getenv("PROXY_HEALTH_STORAGE_PROBE_INTERVAL"); v != "" {
- c.Health.StorageProbeInterval = v
- }
}
// Validate checks the configuration for errors.
@@ -521,14 +481,6 @@ func (c *Config) Validate() error {
}
}
- if err := validateMetadataMaxSize(c.MetadataMaxSize); err != nil {
- return err
- }
-
- if err := c.Health.Validate(); err != nil {
- return err
- }
-
if err := c.Gradle.BuildCache.Validate(); err != nil {
return err
}
@@ -536,22 +488,6 @@ func (c *Config) Validate() error {
return nil
}
-// Validate checks the /health configuration. An unset interval is allowed
-// (the cache uses its default); explicit values must parse and be non-negative.
-func (h *HealthConfig) Validate() error {
- if h.StorageProbeInterval == "" || h.StorageProbeInterval == "0" {
- return nil
- }
- d, err := time.ParseDuration(h.StorageProbeInterval)
- if err != nil {
- return fmt.Errorf("invalid health.storage_probe_interval %q: %w", h.StorageProbeInterval, err)
- }
- if d < 0 {
- return fmt.Errorf("invalid health.storage_probe_interval %q: must be non-negative", h.StorageProbeInterval)
- }
- return nil
-}
-
// Validate checks Gradle build cache settings, applying the default upload
// size if unset.
func (g *GradleBuildCacheConfig) Validate() error {
@@ -594,7 +530,6 @@ func (g *GradleBuildCacheConfig) Validate() error {
const (
defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default
defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default
- defaultMetadataMaxSize = 100 << 20
defaultGradleBuildCacheMaxUploadSize = 100 << 20
defaultGradleBuildCacheSweepInterval = 10 * time.Minute
defaultGradleMaxUploadSizeStr = "100MB"
@@ -614,33 +549,6 @@ func (c *Config) ParseMaxSize() int64 {
return size
}
-func validateMetadataMaxSize(s string) error {
- if s == "" {
- return nil
- }
- size, err := ParseSize(s)
- if err != nil {
- return fmt.Errorf("invalid metadata_max_size: %w", err)
- }
- if size <= 0 {
- return fmt.Errorf("invalid metadata_max_size %q: must be positive", s)
- }
- return nil
-}
-
-// ParseMetadataMaxSize returns the maximum metadata response size in bytes.
-// Returns 100MB if unset or invalid.
-func (c *Config) ParseMetadataMaxSize() int64 {
- if c.MetadataMaxSize == "" {
- return defaultMetadataMaxSize
- }
- size, err := ParseSize(c.MetadataMaxSize)
- if err != nil || size <= 0 {
- return defaultMetadataMaxSize
- }
- return size
-}
-
// ParseMetadataTTL returns the metadata TTL duration.
// Returns 5 minutes if unset, 0 if explicitly disabled.
func (c *Config) ParseMetadataTTL() time.Duration {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index d633c25..26a0fc6 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -31,12 +31,6 @@ 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.Upstream.Maven != "https://repo1.maven.org/maven2" {
- t.Errorf("Upstream.Maven = %q, want %q", cfg.Upstream.Maven, "https://repo1.maven.org/maven2")
- }
- if cfg.Upstream.GradlePluginPortal != "https://plugins.gradle.org/m2" {
- t.Errorf("Upstream.GradlePluginPortal = %q, want %q", cfg.Upstream.GradlePluginPortal, "https://plugins.gradle.org/m2")
- }
}
func TestValidate(t *testing.T) {
@@ -270,8 +264,6 @@ func TestLoadFromEnv(t *testing.T) {
t.Setenv("PROXY_BASE_URL", "https://env.example.com")
t.Setenv("PROXY_STORAGE_PATH", "/env/cache")
t.Setenv("PROXY_LOG_LEVEL", testLevelDebug)
- t.Setenv("PROXY_UPSTREAM_MAVEN", "https://maven.example.com/repository/maven-public")
- t.Setenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL", "https://plugins.example.com/m2")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", "true")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE", "12h")
@@ -292,12 +284,6 @@ func TestLoadFromEnv(t *testing.T) {
if cfg.Log.Level != testLevelDebug {
t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, testLevelDebug)
}
- if cfg.Upstream.Maven != "https://maven.example.com/repository/maven-public" {
- t.Errorf("Upstream.Maven = %q, want %q", cfg.Upstream.Maven, "https://maven.example.com/repository/maven-public")
- }
- if cfg.Upstream.GradlePluginPortal != "https://plugins.example.com/m2" {
- t.Errorf("Upstream.GradlePluginPortal = %q, want %q", cfg.Upstream.GradlePluginPortal, "https://plugins.example.com/m2")
- }
if !cfg.Gradle.BuildCache.ReadOnly {
t.Error("Gradle.BuildCache.ReadOnly = false, want true")
}
@@ -428,52 +414,6 @@ func TestParseMetadataTTL(t *testing.T) {
}
}
-func TestParseMetadataMaxSize(t *testing.T) {
- tests := []struct {
- name string
- size string
- want int64
- }{
- {"unset uses default", "", defaultMetadataMaxSize},
- {"explicit value", "250MB", 250 << 20},
- {"bytes", "1024", 1024},
- {"invalid uses default", "lots", defaultMetadataMaxSize},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cfg := Default()
- cfg.MetadataMaxSize = tt.size
- got := cfg.ParseMetadataMaxSize()
- if got != tt.want {
- t.Errorf("ParseMetadataMaxSize() = %d, want %d", got, tt.want)
- }
- })
- }
-}
-
-func TestValidateMetadataMaxSize(t *testing.T) {
- cfg := Default()
- cfg.MetadataMaxSize = "not-a-size"
- if err := cfg.Validate(); err == nil {
- t.Error("expected validation error for invalid metadata_max_size")
- }
-
- cfg.MetadataMaxSize = "0"
- if err := cfg.Validate(); err == nil {
- t.Error("expected validation error for zero metadata_max_size")
- }
-
- cfg.MetadataMaxSize = "250MB"
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for valid metadata_max_size: %v", err)
- }
-
- cfg.MetadataMaxSize = ""
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for unset metadata_max_size: %v", err)
- }
-}
-
func TestValidateMetadataTTL(t *testing.T) {
cfg := Default()
cfg.MetadataTTL = "invalid"
@@ -492,34 +432,6 @@ func TestValidateMetadataTTL(t *testing.T) {
}
}
-func TestValidateHealthStorageProbeInterval(t *testing.T) {
- cfg := Default()
- cfg.Health.StorageProbeInterval = "not-a-duration"
- if err := cfg.Validate(); err == nil {
- t.Error("expected validation error for invalid health.storage_probe_interval")
- }
-
- cfg.Health.StorageProbeInterval = "30s"
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for valid health.storage_probe_interval: %v", err)
- }
-
- cfg.Health.StorageProbeInterval = "0"
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for zero health.storage_probe_interval: %v", err)
- }
-
- cfg.Health.StorageProbeInterval = ""
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for empty health.storage_probe_interval: %v", err)
- }
-
- cfg.Health.StorageProbeInterval = "-5s"
- if err := cfg.Validate(); err == nil {
- t.Error("expected validation error for negative health.storage_probe_interval")
- }
-}
-
func TestLoadMetadataTTLFromEnv(t *testing.T) {
cfg := Default()
t.Setenv("PROXY_METADATA_TTL", "10m")
diff --git a/internal/cooldown/cooldown.go b/internal/cooldown/cooldown.go
new file mode 100644
index 0000000..f37a2b9
--- /dev/null
+++ b/internal/cooldown/cooldown.go
@@ -0,0 +1,125 @@
+package cooldown
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const hoursPerDay = 24
+
+// Config holds cooldown settings for version filtering.
+// Cooldown hides package versions published too recently, giving the community
+// time to spot malicious releases before they're pulled into projects.
+type Config struct {
+ // Default is the global default cooldown duration (e.g., "3d", "48h").
+ Default string `json:"default" yaml:"default"`
+
+ // Ecosystems overrides the default for specific ecosystems.
+ // Keys are ecosystem names (e.g., "npm", "pypi").
+ Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"`
+
+ // Packages overrides the cooldown for specific packages.
+ // Keys are PURLs (e.g., "pkg:npm/lodash", "pkg:npm/@babel/core").
+ Packages map[string]string `json:"packages" yaml:"packages"`
+
+ defaultDuration time.Duration
+ ecosystemDurations map[string]time.Duration
+ packageDurations map[string]time.Duration
+ parsed bool
+}
+
+// parse resolves all string durations into time.Duration values.
+// Called lazily on first use.
+func (c *Config) parse() {
+ if c.parsed {
+ return
+ }
+ c.parsed = true
+
+ c.defaultDuration, _ = ParseDuration(c.Default)
+
+ c.ecosystemDurations = make(map[string]time.Duration, len(c.Ecosystems))
+ for k, v := range c.Ecosystems {
+ d, _ := ParseDuration(v)
+ c.ecosystemDurations[k] = d
+ }
+
+ c.packageDurations = make(map[string]time.Duration, len(c.Packages))
+ for k, v := range c.Packages {
+ d, _ := ParseDuration(v)
+ c.packageDurations[k] = d
+ }
+}
+
+// For returns the effective cooldown duration for a given ecosystem and package PURL.
+// Resolution order: package override > ecosystem override > global default.
+func (c *Config) For(ecosystem, packagePURL string) time.Duration {
+ c.parse()
+
+ if d, ok := c.packageDurations[packagePURL]; ok {
+ return d
+ }
+ if d, ok := c.ecosystemDurations[ecosystem]; ok {
+ return d
+ }
+ return c.defaultDuration
+}
+
+// IsAllowed returns true if a version with the given publish time has passed
+// the cooldown period for this ecosystem/package.
+func (c *Config) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool {
+ d := c.For(ecosystem, packagePURL)
+ if d == 0 {
+ return true
+ }
+ if publishedAt.IsZero() {
+ return true
+ }
+ return time.Since(publishedAt) >= d
+}
+
+// Enabled returns true if any cooldown is configured.
+func (c *Config) Enabled() bool {
+ c.parse()
+ if c.defaultDuration > 0 {
+ return true
+ }
+ for _, d := range c.ecosystemDurations {
+ if d > 0 {
+ return true
+ }
+ }
+ for _, d := range c.packageDurations {
+ if d > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+// ParseDuration parses a duration string supporting days (e.g., "3d"),
+// in addition to Go's standard time.ParseDuration formats ("48h", "30m").
+// "0" means disabled (returns 0).
+func ParseDuration(s string) (time.Duration, error) {
+ s = strings.TrimSpace(s)
+ if s == "" || s == "0" {
+ return 0, nil
+ }
+
+ // Handle day suffix
+ if numStr, ok := strings.CutSuffix(s, "d"); ok {
+ days, err := strconv.ParseFloat(numStr, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid duration %q: %w", s, err)
+ }
+ return time.Duration(days * float64(hoursPerDay*time.Hour)), nil
+ }
+
+ d, err := time.ParseDuration(s)
+ if err != nil {
+ return 0, fmt.Errorf("invalid duration %q: %w", s, err)
+ }
+ return d, nil
+}
diff --git a/internal/cooldown/cooldown_test.go b/internal/cooldown/cooldown_test.go
new file mode 100644
index 0000000..c366077
--- /dev/null
+++ b/internal/cooldown/cooldown_test.go
@@ -0,0 +1,133 @@
+package cooldown
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParseDuration(t *testing.T) {
+ tests := []struct {
+ input string
+ want time.Duration
+ wantErr bool
+ }{
+ {"", 0, false},
+ {"0", 0, false},
+ {"3d", 3 * 24 * time.Hour, false},
+ {"7d", 7 * 24 * time.Hour, false},
+ {"14d", 14 * 24 * time.Hour, false},
+ {"1.5d", 36 * time.Hour, false},
+ {"48h", 48 * time.Hour, false},
+ {"30m", 30 * time.Minute, false},
+ {"1h30m", 90 * time.Minute, false},
+ {"invalid", 0, true},
+ {"d", 0, true},
+ {"xd", 0, true},
+ }
+
+ for _, tt := range tests {
+ got, err := ParseDuration(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ParseDuration(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
+ continue
+ }
+ if got != tt.want {
+ t.Errorf("ParseDuration(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestConfigFor(t *testing.T) {
+ c := &Config{
+ Default: "3d",
+ Ecosystems: map[string]string{
+ "npm": "7d",
+ "cargo": "0",
+ },
+ Packages: map[string]string{
+ "pkg:npm/lodash": "0",
+ "pkg:npm/@babel/core": "14d",
+ },
+ }
+
+ tests := []struct {
+ ecosystem string
+ packagePURL string
+ want time.Duration
+ }{
+ // Package override takes priority
+ {"npm", "pkg:npm/lodash", 0},
+ {"npm", "pkg:npm/@babel/core", 14 * 24 * time.Hour},
+ // Ecosystem override
+ {"npm", "pkg:npm/express", 7 * 24 * time.Hour},
+ {"cargo", "pkg:cargo/serde", 0},
+ // Global default
+ {"pypi", "pkg:pypi/requests", 3 * 24 * time.Hour},
+ {"pub", "pkg:pub/flutter", 3 * 24 * time.Hour},
+ }
+
+ for _, tt := range tests {
+ got := c.For(tt.ecosystem, tt.packagePURL)
+ if got != tt.want {
+ t.Errorf("For(%q, %q) = %v, want %v", tt.ecosystem, tt.packagePURL, got, tt.want)
+ }
+ }
+}
+
+func TestConfigIsAllowed(t *testing.T) {
+ c := &Config{
+ Default: "3d",
+ Packages: map[string]string{
+ "pkg:npm/lodash": "0",
+ },
+ }
+
+ now := time.Now()
+
+ tests := []struct {
+ name string
+ ecosystem string
+ packagePURL string
+ publishedAt time.Time
+ want bool
+ }{
+ {"old enough", "npm", "pkg:npm/express", now.Add(-4 * 24 * time.Hour), true},
+ {"too recent", "npm", "pkg:npm/express", now.Add(-1 * 24 * time.Hour), false},
+ {"exactly at boundary", "npm", "pkg:npm/express", now.Add(-3 * 24 * time.Hour), true},
+ {"exempt package", "npm", "pkg:npm/lodash", now.Add(-1 * time.Minute), true},
+ {"zero time", "npm", "pkg:npm/express", time.Time{}, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := c.IsAllowed(tt.ecosystem, tt.packagePURL, tt.publishedAt)
+ if got != tt.want {
+ t.Errorf("IsAllowed(%q, %q, %v) = %v, want %v",
+ tt.ecosystem, tt.packagePURL, tt.publishedAt, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestConfigEnabled(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg Config
+ want bool
+ }{
+ {"empty config", Config{}, false},
+ {"default only", Config{Default: "3d"}, true},
+ {"ecosystem only", Config{Ecosystems: map[string]string{"npm": "7d"}}, true},
+ {"package only", Config{Packages: map[string]string{"pkg:npm/x": "1d"}}, true},
+ {"all zero", Config{Default: "0", Ecosystems: map[string]string{"npm": "0"}}, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tt.cfg.Enabled()
+ if got != tt.want {
+ t.Errorf("Enabled() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/handler/cargo_test.go b/internal/handler/cargo_test.go
index 10d3faf..5ce81b6 100644
--- a/internal/handler/cargo_test.go
+++ b/internal/handler/cargo_test.go
@@ -9,7 +9,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
)
func cargoTestProxy() *Proxy {
diff --git a/internal/handler/composer.go b/internal/handler/composer.go
index 065ddf9..0933ece 100644
--- a/internal/handler/composer.go
+++ b/internal/handler/composer.go
@@ -16,7 +16,6 @@ import (
const (
composerUpstream = "https://packagist.org"
composerRepo = "https://repo.packagist.org"
- composerUnset = "__unset"
vendorPackageParts = 2
)
@@ -151,8 +150,7 @@ func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) {
// expandMinifiedVersions expands the Composer v2 minified format where each
// version entry only contains fields that differ from the previous entry.
-// The "~dev" sentinel string resets the inheritance chain, and the "__unset"
-// value removes a field from the inherited state.
+// The "~dev" sentinel string resets the inheritance chain.
func expandMinifiedVersions(versionList []any) []any {
expanded := make([]any, 0, len(versionList))
inherited := map[string]any{}
@@ -176,10 +174,6 @@ func expandMinifiedVersions(versionList []any) []any {
merged[k] = deepCopyValue(val)
}
for k, val := range vmap {
- if val == composerUnset {
- delete(merged, k)
- continue
- }
merged[k] = val
}
diff --git a/internal/handler/composer_test.go b/internal/handler/composer_test.go
index baf13b6..94ff8cb 100644
--- a/internal/handler/composer_test.go
+++ b/internal/handler/composer_test.go
@@ -7,7 +7,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
)
func TestComposerRewriteMetadata(t *testing.T) {
@@ -177,80 +177,6 @@ func TestComposerRewriteMetadataMinifiedDevReset(t *testing.T) {
}
}
-func TestComposerRewriteMetadataUnset(t *testing.T) {
- h := &ComposerHandler{
- proxy: &Proxy{Logger: slog.Default()},
- proxyURL: "http://localhost:8080",
- }
-
- // In the minified format, "__unset" removes a field from the inherited
- // state. v1.29.0 has require-dev, v1.28.0 unsets it, v1.27.0 inherits the
- // unset state. Composer rejects metadata where require-dev (or any link
- // field) is the literal string "__unset" rather than an object.
- input := `{
- "minified": "composer/2.0",
- "packages": {
- "venturecraft/revisionable": [
- {
- "name": "venturecraft/revisionable",
- "version": "1.29.0",
- "require": {"php": ">=5.4"},
- "require-dev": {"orchestra/testbench": "~3.0"},
- "dist": {"url": "https://example.com/a.zip", "type": "zip"}
- },
- {
- "version": "1.28.0",
- "require-dev": "__unset"
- },
- {
- "version": "1.27.0"
- },
- {
- "version": "1.26.0",
- "require-dev": {"foo/bar": "1.0"}
- }
- ]
- }
- }`
-
- output, err := h.rewriteMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- versions := result["packages"].(map[string]any)["venturecraft/revisionable"].([]any)
- if len(versions) != 4 {
- t.Fatalf("expected 4 versions, got %d", len(versions))
- }
-
- byVersion := map[string]map[string]any{}
- for _, v := range versions {
- vmap := v.(map[string]any)
- byVersion[vmap["version"].(string)] = vmap
- }
-
- if _, ok := byVersion["1.29.0"]["require-dev"].(map[string]any); !ok {
- t.Errorf("1.29.0 require-dev should be an object, got %T", byVersion["1.29.0"]["require-dev"])
- }
- if rd, ok := byVersion["1.28.0"]["require-dev"]; ok {
- t.Errorf("1.28.0 require-dev should be absent, got %v", rd)
- }
- if rd, ok := byVersion["1.27.0"]["require-dev"]; ok {
- t.Errorf("1.27.0 require-dev should be absent (inherited unset), got %v", rd)
- }
- if _, ok := byVersion["1.26.0"]["require-dev"].(map[string]any); !ok {
- t.Errorf("1.26.0 require-dev should be an object, got %T", byVersion["1.26.0"]["require-dev"])
- }
- if _, ok := byVersion["1.27.0"]["require"].(map[string]any); !ok {
- t.Error("1.27.0 should still inherit require from 1.29.0")
- }
-}
-
func TestComposerRewriteMetadataCooldownPreservesNames(t *testing.T) {
now := time.Now()
old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
diff --git a/internal/handler/conda.go b/internal/handler/conda.go
index cfa20c8..1336f94 100644
--- a/internal/handler/conda.go
+++ b/internal/handler/conda.go
@@ -161,7 +161,7 @@ func (h *CondaHandler) handleRepodata(w http.ResponseWriter, r *http.Request) {
return
}
- body, err := h.proxy.ReadMetadata(resp.Body)
+ body, err := ReadMetadata(resp.Body)
if err != nil {
http.Error(w, "failed to read response", http.StatusInternalServerError)
return
diff --git a/internal/handler/conda_test.go b/internal/handler/conda_test.go
index 1b57039..24b0236 100644
--- a/internal/handler/conda_test.go
+++ b/internal/handler/conda_test.go
@@ -8,7 +8,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
)
func TestCondaParseFilename(t *testing.T) {
diff --git a/internal/handler/download_test.go b/internal/handler/download_test.go
index 980e234..639e976 100644
--- a/internal/handler/download_test.go
+++ b/internal/handler/download_test.go
@@ -673,7 +673,7 @@ func TestMavenHandler_DownloadCacheHit(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackageWithPURL(t, db, store, "maven", "com.google.guava:guava", "32.1.3-jre", "guava-32.1.3-jre.jar", "jar content")
- h := NewMavenHandler(proxy, "http://localhost", "", "")
+ h := NewMavenHandler(proxy, "http://localhost")
srv := httptest.NewServer(h.Routes())
defer srv.Close()
@@ -730,7 +730,7 @@ func TestMavenHandler_MetadataProxied(t *testing.T) {
func TestMavenHandler_EmptyPathNotFound(t *testing.T) {
proxy, _, _, _ := setupTestProxy(t)
- h := NewMavenHandler(proxy, "http://localhost", "", "")
+ h := NewMavenHandler(proxy, "http://localhost")
srv := httptest.NewServer(h.Routes())
defer srv.Close()
@@ -748,7 +748,7 @@ func TestMavenHandler_EmptyPathNotFound(t *testing.T) {
func TestMavenHandler_ArtifactExtensions(t *testing.T) {
proxy, _, _, fetcher := setupTestProxy(t)
- extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib", ".module"}
+ extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib"}
for _, ext := range extensions {
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("artifact")),
@@ -756,7 +756,7 @@ func TestMavenHandler_ArtifactExtensions(t *testing.T) {
}
fetcher.fetchCalled = false
- h := NewMavenHandler(proxy, "http://localhost", "", "")
+ h := NewMavenHandler(proxy, "http://localhost")
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("should not proxy artifact file %s to upstream", ext)
@@ -789,7 +789,7 @@ func TestMavenHandler_CacheMiss(t *testing.T) {
ContentType: "application/java-archive",
}
- h := NewMavenHandler(proxy, "http://localhost", "", "")
+ h := NewMavenHandler(proxy, "http://localhost")
srv := httptest.NewServer(h.Routes())
defer srv.Close()
@@ -809,274 +809,6 @@ func TestMavenHandler_CacheMiss(t *testing.T) {
}
}
-func TestMavenHandler_GradlePluginMarkerFallbackAndCache(t *testing.T) {
- tests := []struct {
- name string
- markerPath string
- }{
- {
- name: "Spotless",
- markerPath: "/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom",
- },
- {
- name: "BenManes",
- markerPath: "/com/github/ben-manes/versions/com.github.ben-manes.versions.gradle.plugin/0.54.0/com.github.ben-manes.versions.gradle.plugin-0.54.0.pom",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
-
- primaryUpstream := "https://repo1.maven.org/maven2"
- pluginPortalUpstream := "https://plugins.gradle.org/m2"
- primaryURL := primaryUpstream + tt.markerPath
-
- fetcher.fetchErrByURL = map[string]error{
- primaryURL: ErrUpstreamNotFound,
- }
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("")),
- ContentType: "application/xml",
- }
-
- h := NewMavenHandler(proxy, "http://localhost", primaryUpstream, pluginPortalUpstream)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + tt.markerPath)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- if string(body) != "" {
- t.Fatalf("body = %q, want %q", body, "")
- }
-
- wantFallbackURL := pluginPortalUpstream + tt.markerPath
- if fetcher.fetchedURL != wantFallbackURL {
- t.Fatalf("fallback URL = %q, want %q", fetcher.fetchedURL, wantFallbackURL)
- }
-
- fetcher.fetchCalled = false
- resp, err = http.Get(srv.URL + tt.markerPath)
- if err != nil {
- t.Fatalf("second request failed: %v", err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("second status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- if fetcher.fetchCalled {
- t.Fatal("expected plugin marker POM to be served from cache on second request")
- }
- })
- }
-}
-
-func TestMavenHandler_GradlePluginMarkerMetadataFallback(t *testing.T) {
- paths := map[string]string{
- "/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom.sha1": "sha1",
- "/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom.sha256": "sha256",
- "/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom.md5": "md5",
- "/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/maven-metadata.xml": "",
- }
-
- primaryHits := map[string]int{}
- pluginHits := map[string]int{}
-
- primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- primaryHits[r.URL.Path]++
- if _, ok := paths[r.URL.Path]; ok {
- http.NotFound(w, r)
- return
- }
- t.Fatalf("unexpected path to primary upstream: %s", r.URL.Path)
- }))
- defer primary.Close()
-
- pluginPortal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- pluginHits[r.URL.Path]++
- body, ok := paths[r.URL.Path]
- if !ok {
- http.NotFound(w, r)
- return
- }
- _, _ = io.WriteString(w, body)
- }))
- defer pluginPortal.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = primary.Client()
-
- h := NewMavenHandler(proxy, "http://localhost", primary.URL, pluginPortal.URL)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- for reqPath, wantBody := range paths {
- resp, err := http.Get(srv.URL + reqPath)
- if err != nil {
- t.Fatalf("GET %s failed: %v", reqPath, err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("GET %s: status = %d, want %d", reqPath, resp.StatusCode, http.StatusOK)
- }
- if string(body) != wantBody {
- t.Fatalf("GET %s: body = %q, want %q", reqPath, body, wantBody)
- }
-
- if primaryHits[reqPath] == 0 {
- t.Fatalf("GET %s did not hit primary upstream", reqPath)
- }
- if pluginHits[reqPath] == 0 {
- t.Fatalf("GET %s did not hit plugin portal fallback", reqPath)
- }
- }
-}
-
-func TestMavenHandler_GradlePluginImplementationMetadataFallback(t *testing.T) {
- paths := map[string]string{
- "/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar.sha1": "impl-sha1",
- "/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar.sha256": "impl-sha256",
- }
-
- primaryHits := map[string]int{}
- pluginHits := map[string]int{}
-
- primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- primaryHits[r.URL.Path]++
- if _, ok := paths[r.URL.Path]; ok {
- http.NotFound(w, r)
- return
- }
- t.Fatalf("unexpected path to primary upstream: %s", r.URL.Path)
- }))
- defer primary.Close()
-
- pluginPortal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- pluginHits[r.URL.Path]++
- body, ok := paths[r.URL.Path]
- if !ok {
- http.NotFound(w, r)
- return
- }
- _, _ = io.WriteString(w, body)
- }))
- defer pluginPortal.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = primary.Client()
-
- h := NewMavenHandler(proxy, "http://localhost", primary.URL, pluginPortal.URL)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- for reqPath, wantBody := range paths {
- resp, err := http.Get(srv.URL + reqPath)
- if err != nil {
- t.Fatalf("GET %s failed: %v", reqPath, err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("GET %s: status = %d, want %d", reqPath, resp.StatusCode, http.StatusOK)
- }
- if string(body) != wantBody {
- t.Fatalf("GET %s: body = %q, want %q", reqPath, body, wantBody)
- }
-
- if primaryHits[reqPath] == 0 {
- t.Fatalf("GET %s did not hit primary upstream", reqPath)
- }
- if pluginHits[reqPath] == 0 {
- t.Fatalf("GET %s did not hit plugin portal fallback", reqPath)
- }
- }
-}
-
-func TestMavenHandler_GradlePluginImplementation_FallbackToPluginPortal(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
-
- primaryUpstream := "https://repo1.maven.org/maven2"
- pluginPortalUpstream := "https://plugins.gradle.org/m2"
- implPath := "/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar"
- primaryURL := primaryUpstream + implPath
- pluginPortalURL := pluginPortalUpstream + implPath
-
- fetcher.fetchErrByURL = map[string]error{
- primaryURL: ErrUpstreamNotFound,
- }
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("plugin impl jar")),
- ContentType: "application/java-archive",
- }
-
- h := NewMavenHandler(proxy, "http://localhost", primaryUpstream, pluginPortalUpstream)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + implPath)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- if string(body) != "plugin impl jar" {
- t.Fatalf("body = %q, want %q", body, "plugin impl jar")
- }
-
- if fetcher.fetchedURL != pluginPortalURL {
- t.Fatalf("implementation artifact should fallback to plugin portal; fetched URL = %q, want %q", fetcher.fetchedURL, pluginPortalURL)
- }
-}
-
-func TestMavenHandler_GradlePluginImplementation_NotFoundInBothUpstreams(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
-
- primaryUpstream := "https://repo1.maven.org/maven2"
- pluginPortalUpstream := "https://plugins.gradle.org/m2"
- implPath := "/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar"
- primaryURL := primaryUpstream + implPath
- pluginPortalURL := pluginPortalUpstream + implPath
-
- fetcher.fetchErrByURL = map[string]error{
- primaryURL: ErrUpstreamNotFound,
- pluginPortalURL: ErrUpstreamNotFound,
- }
-
- h := NewMavenHandler(proxy, "http://localhost", primaryUpstream, pluginPortalUpstream)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + implPath)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusNotFound {
- t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
- }
-
- if fetcher.fetchedURL != pluginPortalURL {
- t.Fatalf("expected fallback attempt to plugin portal; fetched URL = %q, want %q", fetcher.fetchedURL, pluginPortalURL)
- }
-}
-
func TestNuGetHandler_DownloadCacheMiss(t *testing.T) {
proxy, _, _, fetcher := setupTestProxy(t)
fetcher.artifact = &fetch.Artifact{
diff --git a/internal/handler/gem_test.go b/internal/handler/gem_test.go
index 7d90946..6dce324 100644
--- a/internal/handler/gem_test.go
+++ b/internal/handler/gem_test.go
@@ -10,7 +10,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
)
func TestGemParseFilename(t *testing.T) {
diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go
index 3b703be..aed98e7 100644
--- a/internal/handler/gradle.go
+++ b/internal/handler/gradle.go
@@ -7,9 +7,7 @@ import (
"regexp"
"strconv"
"strings"
- "time"
- "github.com/git-pkgs/proxy/internal/metrics"
"github.com/git-pkgs/proxy/internal/storage"
)
@@ -96,28 +94,18 @@ func (h *GradleBuildCacheHandler) handleGetOrHead(w http.ResponseWriter, r *http
w.Header().Set("Content-Type", gradleBuildCacheContentType)
if r.Method == http.MethodHead {
- existsStart := time.Now()
exists, err := h.proxy.Storage.Exists(r.Context(), storagePath)
- metrics.RecordStorageOperation("read", time.Since(existsStart))
if err != nil {
- metrics.RecordStorageError("read")
h.proxy.Logger.Error("failed to check gradle build cache entry", "key", key, "error", err)
http.Error(w, "failed to read cache entry", http.StatusInternalServerError)
return
}
if !exists {
- metrics.RecordCacheMiss("gradle")
http.NotFound(w, r)
return
}
- metrics.RecordCacheHit("gradle")
- sizeStart := time.Now()
- size, err := h.proxy.Storage.Size(r.Context(), storagePath)
- metrics.RecordStorageOperation("read", time.Since(sizeStart))
- if err != nil {
- metrics.RecordStorageError("read")
- } else if size >= 0 {
+ if size, err := h.proxy.Storage.Size(r.Context(), storagePath); err == nil && size >= 0 {
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
}
@@ -125,22 +113,17 @@ func (h *GradleBuildCacheHandler) handleGetOrHead(w http.ResponseWriter, r *http
return
}
- readStart := time.Now()
reader, err := h.proxy.Storage.Open(r.Context(), storagePath)
- metrics.RecordStorageOperation("read", time.Since(readStart))
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
- metrics.RecordCacheMiss("gradle")
http.NotFound(w, r)
return
}
- metrics.RecordStorageError("read")
h.proxy.Logger.Error("failed to open gradle build cache entry", "key", key, "error", err)
http.Error(w, "failed to read cache entry", http.StatusInternalServerError)
return
}
defer func() { _ = reader.Close() }()
- metrics.RecordCacheHit("gradle")
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, reader)
@@ -155,9 +138,7 @@ func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Reque
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
- storeStart := time.Now()
_, hash, err := h.proxy.Storage.Store(r.Context(), storagePath, r.Body)
- metrics.RecordStorageOperation("write", time.Since(storeStart))
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
@@ -165,7 +146,6 @@ func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Reque
return
}
- metrics.RecordStorageError("write")
h.proxy.Logger.Error("failed to store gradle build cache entry", "key", key, "error", err)
http.Error(w, "failed to write cache entry", http.StatusInternalServerError)
return
diff --git a/internal/handler/gradle_test.go b/internal/handler/gradle_test.go
index a05d07e..f002a51 100644
--- a/internal/handler/gradle_test.go
+++ b/internal/handler/gradle_test.go
@@ -6,9 +6,6 @@ import (
"net/http/httptest"
"strings"
"testing"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/prometheus/client_golang/prometheus/testutil"
)
func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) {
@@ -230,56 +227,3 @@ func TestGradleBuildCacheHandler_PutTooLarge(t *testing.T) {
t.Fatalf("PUT status = %d, want %d", resp.StatusCode, http.StatusRequestEntityTooLarge)
}
}
-
-func TestGradleBuildCacheHandler_RecordsMetrics(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGradleBuildCacheHandler(proxy)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- hitsBefore := testutil.ToFloat64(metrics.CacheHits.WithLabelValues("gradle"))
- missesBefore := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("gradle"))
-
- key := "metrics-key"
- putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader("payload"))
- if err != nil {
- t.Fatalf("failed to create PUT request: %v", err)
- }
- putResp, err := http.DefaultClient.Do(putReq)
- if err != nil {
- t.Fatalf("PUT request failed: %v", err)
- }
- _ = putResp.Body.Close()
-
- getResp, err := http.Get(srv.URL + "/" + key)
- if err != nil {
- t.Fatalf("GET request failed: %v", err)
- }
- _ = getResp.Body.Close()
-
- headReq, err := http.NewRequest(http.MethodHead, srv.URL+"/"+key, nil)
- if err != nil {
- t.Fatalf("failed to create HEAD request: %v", err)
- }
- headResp, err := http.DefaultClient.Do(headReq)
- if err != nil {
- t.Fatalf("HEAD request failed: %v", err)
- }
- _ = headResp.Body.Close()
-
- missResp, err := http.Get(srv.URL + "/missing-key")
- if err != nil {
- t.Fatalf("GET miss request failed: %v", err)
- }
- _ = missResp.Body.Close()
-
- hitsAfter := testutil.ToFloat64(metrics.CacheHits.WithLabelValues("gradle"))
- missesAfter := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("gradle"))
-
- if diff := hitsAfter - hitsBefore; diff != 2 {
- t.Fatalf("cache hits delta = %.0f, want 2", diff)
- }
- if diff := missesAfter - missesBefore; diff != 1 {
- t.Fatalf("cache misses delta = %.0f, want 1", diff)
- }
-}
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index d06ca83..78f17cb 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -15,7 +15,7 @@ import (
"strings"
"time"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/metrics"
"github.com/git-pkgs/proxy/internal/storage"
@@ -52,25 +52,23 @@ const contentTypeJSON = "application/json"
const headerAcceptEncoding = "Accept-Encoding"
-// defaultMetadataMaxSize is used when Proxy.MetadataMaxSize is unset.
-const defaultMetadataMaxSize = 100 << 20
+// maxMetadataSize is the maximum size of upstream metadata responses (100 MB).
+// Package metadata (e.g. npm with many versions) can be large, but unbounded
+// reads risk OOM if an upstream misbehaves.
+const maxMetadataSize = 100 << 20
-// ErrMetadataTooLarge is returned when upstream metadata exceeds the configured limit.
+// ErrMetadataTooLarge is returned when upstream metadata exceeds maxMetadataSize.
var ErrMetadataTooLarge = errors.New("metadata response exceeds size limit")
// ReadMetadata reads an upstream response body with a size limit to prevent OOM
// from unexpectedly large responses. Returns ErrMetadataTooLarge if the response
// is truncated by the limit.
-func (p *Proxy) ReadMetadata(r io.Reader) ([]byte, error) {
- limit := p.MetadataMaxSize
- if limit <= 0 {
- limit = defaultMetadataMaxSize
- }
- data, err := io.ReadAll(io.LimitReader(r, limit+1))
+func ReadMetadata(r io.Reader) ([]byte, error) {
+ data, err := io.ReadAll(io.LimitReader(r, maxMetadataSize+1))
if err != nil {
return nil, err
}
- if int64(len(data)) > limit {
+ if int64(len(data)) > maxMetadataSize {
return nil, ErrMetadataTooLarge
}
return data, nil
@@ -86,7 +84,6 @@ type Proxy struct {
Cooldown *cooldown.Config
CacheMetadata bool
MetadataTTL time.Duration
- MetadataMaxSize int64
GradleReadOnly bool
GradleMaxUploadSize int64
DirectServe bool
@@ -477,7 +474,7 @@ func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u
cached, readErr := p.Storage.Open(ctx, entry.StoragePath)
if readErr == nil {
defer func() { _ = cached.Close() }()
- data, readErr := p.ReadMetadata(cached)
+ data, readErr := ReadMetadata(cached)
if readErr == nil {
ct := contentTypeJSON
if entry.ContentType.Valid {
@@ -522,7 +519,7 @@ func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u
}
defer func() { _ = cached.Close() }()
- data, readErr := p.ReadMetadata(cached)
+ data, readErr := ReadMetadata(cached)
if readErr != nil {
return nil, "", fmt.Errorf("upstream failed and cached read error: %w", err)
}
@@ -564,7 +561,7 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e
return nil, "", "", zeroTime, errStale304
}
defer func() { _ = cached.Close() }()
- data, readErr := p.ReadMetadata(cached)
+ data, readErr := ReadMetadata(cached)
if readErr != nil {
return nil, "", "", zeroTime, errStale304
}
@@ -586,7 +583,7 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e
return nil, "", "", zeroTime, fmt.Errorf("upstream returned %d", resp.StatusCode)
}
- body, err := p.ReadMetadata(resp.Body)
+ body, err := ReadMetadata(resp.Body)
if err != nil {
return nil, "", "", zeroTime, fmt.Errorf("reading response: %w", err)
}
@@ -683,14 +680,9 @@ func (p *Proxy) ProxyCached(w http.ResponseWriter, r *http.Request, upstreamURL,
return
}
- p.writeMetadataCachedResponse(w, r, ecosystem, cacheKey, body, contentType)
-}
-
-// writeMetadataCachedResponse writes a cached metadata response and handles
-// conditional request headers using metadata cache validators.
-func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, body []byte, contentType string) {
cm := p.lookupCachedMeta(ecosystem, cacheKey)
+ // Honor client conditional request headers
if cm.etag != "" {
if match := r.Header.Get("If-None-Match"); match != "" && match == cm.etag {
w.WriteHeader(http.StatusNotModified)
diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go
index bbcab72..3a1d2ab 100644
--- a/internal/handler/handler_test.go
+++ b/internal/handler/handler_test.go
@@ -97,11 +97,10 @@ func (s *mockStorage) Close() error { return nil }
// mockFetcher implements fetch.FetcherInterface for testing.
type mockFetcher struct {
- artifact *fetch.Artifact
- fetchErr error
- fetchErrByURL map[string]error
- fetchCalled bool
- fetchedURL string
+ artifact *fetch.Artifact
+ fetchErr error
+ fetchCalled bool
+ fetchedURL string
}
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
@@ -111,11 +110,6 @@ func (f *mockFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, e
func (f *mockFetcher) FetchWithHeaders(_ context.Context, url string, _ http.Header) (*fetch.Artifact, error) {
f.fetchCalled = true
f.fetchedURL = url
- if f.fetchErrByURL != nil {
- if err, ok := f.fetchErrByURL[url]; ok {
- return nil, err
- }
- }
if f.fetchErr != nil {
return nil, f.fetchErr
}
diff --git a/internal/handler/hex_test.go b/internal/handler/hex_test.go
index b02540a..19d34b4 100644
--- a/internal/handler/hex_test.go
+++ b/internal/handler/hex_test.go
@@ -11,7 +11,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
"google.golang.org/protobuf/encoding/protowire"
)
diff --git a/internal/handler/maven.go b/internal/handler/maven.go
index c423645..86664a2 100644
--- a/internal/handler/maven.go
+++ b/internal/handler/maven.go
@@ -1,7 +1,6 @@
package handler
import (
- "errors"
"fmt"
"net/http"
"path"
@@ -9,33 +8,23 @@ import (
)
const (
- mavenCentralUpstream = "https://repo1.maven.org/maven2"
- gradlePluginPortalUpstream = "https://plugins.gradle.org/m2"
- minMavenParts = 4 // group path segments + artifact + version + filename
+ mavenUpstream = "https://repo1.maven.org/maven2"
+ minMavenParts = 4 // group path segments + artifact + version + filename
)
// MavenHandler handles Maven repository protocol requests.
type MavenHandler struct {
- proxy *Proxy
- upstreamURL string
- pluginPortalUpstreamURL string
- proxyURL string
+ proxy *Proxy
+ upstreamURL string
+ proxyURL string
}
// NewMavenHandler creates a new Maven repository handler.
-func NewMavenHandler(proxy *Proxy, proxyURL, upstreamURL, pluginPortalUpstreamURL string) *MavenHandler {
- if strings.TrimSpace(upstreamURL) == "" {
- upstreamURL = mavenCentralUpstream
- }
- if strings.TrimSpace(pluginPortalUpstreamURL) == "" {
- pluginPortalUpstreamURL = gradlePluginPortalUpstream
- }
-
+func NewMavenHandler(proxy *Proxy, proxyURL string) *MavenHandler {
return &MavenHandler{
- proxy: proxy,
- upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
- pluginPortalUpstreamURL: strings.TrimSuffix(pluginPortalUpstreamURL, "/"),
- proxyURL: strings.TrimSuffix(proxyURL, "/"),
+ proxy: proxy,
+ upstreamURL: mavenUpstream,
+ proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
@@ -62,7 +51,8 @@ func (h *MavenHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
filename := path.Base(urlPath)
if h.isMetadataFile(filename) {
- h.handleMetadata(w, r, urlPath)
+ cacheKey := strings.ReplaceAll(urlPath, "/", "_")
+ h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "maven", cacheKey, "*/*")
return
}
@@ -76,32 +66,6 @@ func (h *MavenHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
h.proxyUpstream(w, r)
}
-func (h *MavenHandler) handleMetadata(w http.ResponseWriter, r *http.Request, urlPath string) {
- cacheKey := strings.ReplaceAll(urlPath, "/", "_")
- upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, urlPath)
-
- body, contentType, err := h.proxy.FetchOrCacheMetadata(r.Context(), "maven", cacheKey, upstreamURL, "*/*")
- if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- pluginPortalURL := fmt.Sprintf("%s/%s", h.pluginPortalUpstreamURL, urlPath)
- h.proxy.Logger.Info("maven metadata unavailable in primary upstream, trying Gradle Plugin Portal",
- "path", urlPath)
- body, contentType, err = h.proxy.FetchOrCacheMetadata(r.Context(), "maven", cacheKey, pluginPortalURL, "*/*")
- }
- }
- if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- h.proxy.Logger.Error("metadata fetch failed", "error", err)
- http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
- return
- }
-
- h.proxy.writeMetadataCachedResponse(w, r, "maven", cacheKey, body, contentType)
-}
-
// handleDownload serves an artifact file, fetching and caching from upstream if needed.
func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, urlPath string) {
// Parse Maven path: group/artifact/version/filename
@@ -122,18 +86,6 @@ func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, ur
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "maven", name, version, filename, upstreamURL)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- pluginPortalURL := fmt.Sprintf("%s/%s", h.pluginPortalUpstreamURL, urlPath)
- h.proxy.Logger.Info("maven artifact not found in primary upstream, trying Gradle Plugin Portal",
- "group", group, "artifact", artifact, "version", version, "filename", filename)
- result, err = h.proxy.GetOrFetchArtifactFromURL(r.Context(), "maven", name, version, filename, pluginPortalURL)
- }
- }
- if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch artifact", http.StatusBadGateway)
return
@@ -163,7 +115,7 @@ func (h *MavenHandler) parsePath(urlPath string) (group, artifact, version, file
// isArtifactFile returns true if the filename looks like a Maven artifact.
func (h *MavenHandler) isArtifactFile(filename string) bool {
// Common artifact extensions
- extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib", ".module"}
+ extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib"}
for _, ext := range extensions {
if strings.HasSuffix(filename, ext) {
return true
diff --git a/internal/handler/maven_test.go b/internal/handler/maven_test.go
index 9ca5eb6..df6917c 100644
--- a/internal/handler/maven_test.go
+++ b/internal/handler/maven_test.go
@@ -52,7 +52,6 @@ func TestMavenIsArtifactFile(t *testing.T) {
}{
{"guava-32.1.3-jre.jar", true},
{"guava-32.1.3-jre.pom", true},
- {"guava-32.1.3-jre.module", true},
{"app-1.0.war", true},
{"lib-1.0.aar", true},
{"maven-metadata.xml", false},
@@ -66,63 +65,3 @@ func TestMavenIsArtifactFile(t *testing.T) {
}
}
}
-
-func TestMavenIsMetadataFile(t *testing.T) {
- h := &MavenHandler{}
-
- tests := []struct {
- name string
- filename string
- want bool
- }{
- {
- name: "pom is artifact, not metadata",
- filename: "com.diffplug.spotless.gradle.plugin-8.4.0.pom",
- want: false,
- },
- {
- name: "pom checksum is metadata",
- filename: "com.diffplug.spotless.gradle.plugin-8.4.0.pom.sha1",
- want: true,
- },
- {
- name: "metadata file",
- filename: "maven-metadata.xml",
- want: true,
- },
- {
- name: "metadata checksum",
- filename: "maven-metadata.xml.sha256",
- want: true,
- },
- {
- name: "jar checksum is metadata",
- filename: "guava-32.1.3-jre.jar.sha1",
- want: true,
- },
- {
- name: "asc signature is metadata",
- filename: "guava-32.1.3-jre.jar.asc",
- want: true,
- },
- {
- name: "regular jar is not metadata",
- filename: "guava-32.1.3-jre.jar",
- want: false,
- },
- {
- name: "pom checksum is metadata",
- filename: "guava-32.1.3-jre.pom.sha1",
- want: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := h.isMetadataFile(tt.filename)
- if got != tt.want {
- t.Errorf("isMetadataFile(%q) = %v, want %v", tt.filename, got, tt.want)
- }
- })
- }
-}
diff --git a/internal/handler/npm_test.go b/internal/handler/npm_test.go
index bc1edde..7db3539 100644
--- a/internal/handler/npm_test.go
+++ b/internal/handler/npm_test.go
@@ -8,7 +8,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
)
const testVersion100 = "1.0.0"
diff --git a/internal/handler/nuget.go b/internal/handler/nuget.go
index 40b8b5f..3cce7f8 100644
--- a/internal/handler/nuget.go
+++ b/internal/handler/nuget.go
@@ -193,7 +193,7 @@ func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request
return
}
- body, err := h.proxy.ReadMetadata(resp.Body)
+ body, err := ReadMetadata(resp.Body)
if err != nil {
http.Error(w, "failed to read response", http.StatusInternalServerError)
return
diff --git a/internal/handler/nuget_test.go b/internal/handler/nuget_test.go
index b2164e5..68c9d22 100644
--- a/internal/handler/nuget_test.go
+++ b/internal/handler/nuget_test.go
@@ -10,7 +10,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
)
func nugetTestProxy() *Proxy {
diff --git a/internal/handler/pub_test.go b/internal/handler/pub_test.go
index 8a4c098..2788714 100644
--- a/internal/handler/pub_test.go
+++ b/internal/handler/pub_test.go
@@ -6,7 +6,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
)
func TestPubRewriteMetadata(t *testing.T) {
diff --git a/internal/handler/pypi_test.go b/internal/handler/pypi_test.go
index 2b58960..9e2ade0 100644
--- a/internal/handler/pypi_test.go
+++ b/internal/handler/pypi_test.go
@@ -10,7 +10,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
"github.com/git-pkgs/registries/fetch"
)
diff --git a/internal/handler/read_metadata_test.go b/internal/handler/read_metadata_test.go
index b13bddb..60c1cf2 100644
--- a/internal/handler/read_metadata_test.go
+++ b/internal/handler/read_metadata_test.go
@@ -7,12 +7,9 @@ import (
)
func TestReadMetadata(t *testing.T) {
- const limit = 1024
- p := &Proxy{MetadataMaxSize: limit}
-
t.Run("small body", func(t *testing.T) {
data := []byte("hello world")
- got, err := p.ReadMetadata(bytes.NewReader(data))
+ got, err := ReadMetadata(bytes.NewReader(data))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -22,39 +19,27 @@ func TestReadMetadata(t *testing.T) {
})
t.Run("exactly at limit", func(t *testing.T) {
- data := make([]byte, limit)
+ data := make([]byte, maxMetadataSize)
for i := range data {
data[i] = 'x'
}
- got, err := p.ReadMetadata(bytes.NewReader(data))
+ got, err := ReadMetadata(bytes.NewReader(data))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if len(got) != limit {
- t.Errorf("got length %d, want %d", len(got), limit)
+ if len(got) != int(maxMetadataSize) {
+ t.Errorf("got length %d, want %d", len(got), maxMetadataSize)
}
})
t.Run("over limit returns error", func(t *testing.T) {
- data := make([]byte, limit+100)
+ data := make([]byte, maxMetadataSize+100)
for i := range data {
data[i] = 'x'
}
- _, err := p.ReadMetadata(bytes.NewReader(data))
+ _, err := ReadMetadata(bytes.NewReader(data))
if !errors.Is(err, ErrMetadataTooLarge) {
t.Errorf("got error %v, want ErrMetadataTooLarge", err)
}
})
-
- t.Run("zero limit uses default", func(t *testing.T) {
- p := &Proxy{}
- data := make([]byte, 1<<20)
- got, err := p.ReadMetadata(bytes.NewReader(data))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(got) != len(data) {
- t.Errorf("got length %d, want %d", len(got), len(data))
- }
- })
}
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
index f23a5a9..18ebe88 100644
--- a/internal/metrics/metrics.go
+++ b/internal/metrics/metrics.go
@@ -128,14 +128,6 @@ var (
},
[]string{"ecosystem"},
)
-
- HealthProbeFailures = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_health_probe_failures_total",
- Help: "Total number of storage health probe failures, by step (write|size|read|verify|delete).",
- },
- []string{"step"},
- )
)
func init() {
@@ -155,7 +147,6 @@ func init() {
StorageErrors,
ActiveRequests,
IntegrityFailures,
- HealthProbeFailures,
)
}
@@ -201,12 +192,6 @@ func RecordIntegrityFailure(ecosystem string) {
IntegrityFailures.WithLabelValues(ecosystem).Inc()
}
-// RecordHealthProbeFailure increments the health probe failure counter.
-// step is one of: "write", "size", "read", "verify", "delete".
-func RecordHealthProbeFailure(step string) {
- HealthProbeFailures.WithLabelValues(step).Inc()
-}
-
// RecordStorageError increments storage error counter.
func RecordStorageError(operation string) {
StorageErrors.WithLabelValues(operation).Inc()
diff --git a/internal/server/health.go b/internal/server/health.go
deleted file mode 100644
index f4e4847..0000000
--- a/internal/server/health.go
+++ /dev/null
@@ -1,182 +0,0 @@
-// Package server implements the proxy HTTP server.
-package server
-
-import (
- "bytes"
- "context"
- "crypto/rand"
- "encoding/hex"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "strconv"
- "sync"
- "time"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-const (
- probePathPrefix = ".healthcheck/"
- probeMarker = "proxy-healthcheck:"
- probeSuffixBytes = 8
- defaultProbeTTL = 30 * time.Second
- defaultProbeTimeout = 10 * time.Second
-)
-
-// HealthResponse is the JSON payload returned by /health.
-type HealthResponse struct {
- Status string `json:"status"`
- Checks map[string]HealthCheck `json:"checks"`
-}
-
-// HealthCheck reports the status of a single subsystem check.
-type HealthCheck struct {
- Status string `json:"status"`
- Error string `json:"error,omitempty"`
- Step string `json:"step,omitempty"`
-}
-
-// probeError tags a storage probe failure with the step that failed.
-type probeError struct {
- step string
- err error
-}
-
-func (e *probeError) Error() string { return e.step + ": " + e.err.Error() }
-func (e *probeError) Unwrap() error { return e.err }
-
-// storageProbe runs a write → size-check → read → verify → delete round-trip
-// against the storage backend. Returns nil on success or a *probeError on failure.
-func storageProbe(ctx context.Context, s storage.Storage) (err error) {
- suffix, suffixErr := randomSuffix()
- if suffixErr != nil {
- return &probeError{step: "write", err: fmt.Errorf("generating random suffix: %w", suffixErr)}
- }
- path := probePathPrefix + strconv.FormatInt(time.Now().UnixNano(), 10) + "-" + suffix
- payload := []byte(probeMarker + suffix)
-
- // 1. Store
- size, _, storeErr := s.Store(ctx, path, bytes.NewReader(payload))
- if storeErr != nil {
- return &probeError{step: "write", err: storeErr}
- }
- // After Store succeeds, always attempt to delete on the way out so probe
- // objects don't accumulate when a later step (size/open/read/verify) fails.
- // Delete is reported as the primary error only if no earlier failure
- // already set one.
- defer func() {
- if delErr := s.Delete(ctx, path); delErr != nil && err == nil {
- err = &probeError{step: "delete", err: delErr}
- }
- }()
- // 2. Size check
- if size != int64(len(payload)) {
- return &probeError{step: "size", err: fmt.Errorf("wrote %d bytes, expected %d", size, len(payload))}
- }
- // 3. Open
- rc, openErr := s.Open(ctx, path)
- if openErr != nil {
- return &probeError{step: "read", err: openErr}
- }
- // 4. Read all (classify mid-stream errors as read, not verify).
- // Close explicitly (not deferred) so the file handle is released before
- // Delete — on Windows, an open handle prevents deletion.
- data, readErr := io.ReadAll(rc)
- _ = rc.Close()
- if readErr != nil {
- return &probeError{step: "read", err: readErr}
- }
- // 5. Verify
- if !bytes.Equal(data, payload) {
- return &probeError{step: "verify", err: fmt.Errorf("content mismatch")}
- }
- // 6. Delete is handled via the deferred cleanup above.
- return nil
-}
-
-// randomSuffix returns 8 cryptographically random bytes hex-encoded.
-func randomSuffix() (string, error) {
- b := make([]byte, probeSuffixBytes)
- if _, err := rand.Read(b); err != nil {
- return "", err
- }
- return hex.EncodeToString(b), nil
-}
-
-// healthCache memoizes the result of storageProbe for a configurable TTL.
-// It is safe for concurrent use.
-type healthCache struct {
- storage storage.Storage
- interval time.Duration
- probeTimeout time.Duration
- logger *slog.Logger
-
- mu sync.Mutex
- lastAt time.Time
- lastErr error
-}
-
-// newHealthCache builds a cache, parsing the interval from a duration string.
-// Empty interval string defaults to 30s. "0" or "0s" disables caching.
-func newHealthCache(s storage.Storage, intervalStr string, logger *slog.Logger) (*healthCache, error) {
- interval := defaultProbeTTL
- if intervalStr != "" {
- d, err := time.ParseDuration(intervalStr)
- if err != nil {
- return nil, fmt.Errorf("parsing storage_probe_interval %q: %w", intervalStr, err)
- }
- interval = d
- }
- return &healthCache{
- storage: s,
- interval: interval,
- probeTimeout: defaultProbeTimeout,
- logger: logger,
- }, nil
-}
-
-// Check returns the cached probe result if still fresh, otherwise runs a fresh probe.
-// The probe runs under a context derived from context.Background() with a fixed
-// timeout so that caller cancellation (e.g. client disconnect) cannot poison the
-// cache with context.Canceled.
-func (c *healthCache) Check() error {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- // Cache hit
- if c.interval > 0 && !c.lastAt.IsZero() && time.Since(c.lastAt) < c.interval {
- return c.lastErr
- }
-
- // Fresh probe under a detached context
- probeCtx, cancel := context.WithTimeout(context.Background(), c.probeTimeout)
- defer cancel()
- err := storageProbe(probeCtx, c.storage)
-
- // Transition logging and metric increment happen only on the fresh-probe path.
- c.logTransition(c.lastErr, err)
- if err != nil {
- var pe *probeError
- if errors.As(err, &pe) {
- metrics.RecordHealthProbeFailure(pe.step)
- } else {
- metrics.RecordHealthProbeFailure("unknown")
- }
- }
-
- c.lastErr = err
- c.lastAt = time.Now()
- return err
-}
-
-func (c *healthCache) logTransition(prev, curr error) {
- switch {
- case prev != nil && curr == nil:
- c.logger.Info("storage probe recovered")
- case prev == nil && curr != nil:
- c.logger.Error("storage probe failed", "error", curr.Error())
- }
-}
diff --git a/internal/server/health_test.go b/internal/server/health_test.go
deleted file mode 100644
index c0f70c9..0000000
--- a/internal/server/health_test.go
+++ /dev/null
@@ -1,448 +0,0 @@
-package server
-
-import (
- "bytes"
- "context"
- "errors"
- "io"
- "log/slog"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/proxy/internal/storage"
- "github.com/prometheus/client_golang/prometheus/testutil"
-)
-
-// fakeStorage is a minimal storage.Storage for probe tests with per-step failure injection.
-type fakeStorage struct {
- mu sync.Mutex
-
- storeCalls atomic.Int64
- openCalls atomic.Int64
- closeCalls atomic.Int64
- deleteCalls atomic.Int64
-
- paths []string
- payloads [][]byte
-
- // Failure injection.
- storeErr error
- openErr error
- readErr error // returned by the io.ReadCloser.Read after partial bytes
- deleteErr error
-
- // Misbehavior knobs.
- sizeDelta int64 // added to the reported size from Store
- readOverride []byte // if non-nil, Open returns a reader yielding these bytes instead of stored content
-
- // storeBlock, if non-nil, causes Store to block until the channel is closed or ctx is done.
- storeBlock chan struct{}
-
- stored map[string][]byte
-}
-
-func newFakeStorage() *fakeStorage { return &fakeStorage{stored: map[string][]byte{}} }
-
-func (f *fakeStorage) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
- f.storeCalls.Add(1)
- if f.storeErr != nil {
- return 0, "", f.storeErr
- }
- if f.storeBlock != nil {
- select {
- case <-f.storeBlock:
- case <-ctx.Done():
- return 0, "", ctx.Err()
- }
- }
- data, err := io.ReadAll(r)
- if err != nil {
- return 0, "", err
- }
- f.mu.Lock()
- f.stored[path] = data
- f.paths = append(f.paths, path)
- f.payloads = append(f.payloads, data)
- f.mu.Unlock()
- return int64(len(data)) + f.sizeDelta, "fakehash", nil
-}
-
-type fakeReadCloser struct {
- data []byte
- pos int
- readErr error
- closed *atomic.Int64
-}
-
-func (rc *fakeReadCloser) Read(p []byte) (int, error) {
- if rc.pos >= len(rc.data) {
- if rc.readErr != nil {
- return 0, rc.readErr
- }
- return 0, io.EOF
- }
- n := copy(p, rc.data[rc.pos:])
- rc.pos += n
- if rc.pos >= len(rc.data) && rc.readErr != nil {
- return n, rc.readErr
- }
- return n, nil
-}
-
-func (rc *fakeReadCloser) Close() error { rc.closed.Add(1); return nil }
-
-func (f *fakeStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) {
- f.openCalls.Add(1)
- if f.openErr != nil {
- return nil, f.openErr
- }
- f.mu.Lock()
- data := f.stored[path]
- f.mu.Unlock()
- if f.readOverride != nil {
- data = f.readOverride
- }
- return &fakeReadCloser{data: data, readErr: f.readErr, closed: &f.closeCalls}, nil
-}
-
-func (f *fakeStorage) Exists(ctx context.Context, path string) (bool, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- _, ok := f.stored[path]
- return ok, nil
-}
-
-func (f *fakeStorage) Delete(ctx context.Context, path string) error {
- f.deleteCalls.Add(1)
- if f.deleteErr != nil {
- return f.deleteErr
- }
- f.mu.Lock()
- delete(f.stored, path)
- f.mu.Unlock()
- return nil
-}
-
-func (f *fakeStorage) Size(ctx context.Context, path string) (int64, error) { return 0, nil }
-func (f *fakeStorage) SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error) {
- return "", storage.ErrSignedURLUnsupported
-}
-func (f *fakeStorage) UsedSpace(ctx context.Context) (int64, error) { return 0, nil }
-func (f *fakeStorage) URL() string { return "fake://" }
-func (f *fakeStorage) Close() error { return nil }
-
-// --- Tests follow. First test: happy path ---
-
-func TestStorageProbe_HappyPath(t *testing.T) {
- fs := newFakeStorage()
- if err := storageProbe(context.Background(), fs); err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("Store calls = %d, want 1", got)
- }
- if got := fs.openCalls.Load(); got != 1 {
- t.Errorf("Open calls = %d, want 1", got)
- }
- if got := fs.closeCalls.Load(); got != 1 {
- t.Errorf("Close calls = %d, want 1", got)
- }
- if got := fs.deleteCalls.Load(); got != 1 {
- t.Errorf("Delete calls = %d, want 1", got)
- }
- if len(fs.paths) != 1 || !strings.HasPrefix(fs.paths[0], ".healthcheck/") {
- t.Errorf("unexpected probe path: %v", fs.paths)
- }
-}
-
-func TestStorageProbe_WriteFails(t *testing.T) {
- fs := newFakeStorage()
- fs.storeErr = errors.New("disk full")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) {
- t.Fatalf("expected *probeError, got %T: %v", err, err)
- }
- if pe.step != "write" {
- t.Errorf("step = %q, want write", pe.step)
- }
- if fs.openCalls.Load() != 0 {
- t.Errorf("Open should not be called after write failure")
- }
-}
-
-func TestStorageProbe_SizeMismatch(t *testing.T) {
- fs := newFakeStorage()
- fs.sizeDelta = -1 // Report 1 byte fewer than actually written
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "size" {
- t.Fatalf("step = %v, want size; err = %v", pe, err)
- }
- if fs.openCalls.Load() != 0 {
- t.Errorf("Open should not be called after size mismatch")
- }
-}
-
-func TestStorageProbe_OpenFails(t *testing.T) {
- fs := newFakeStorage()
- fs.openErr = errors.New("access denied")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "read" {
- t.Fatalf("step = %v, want read; err = %v", pe, err)
- }
-}
-
-func TestStorageProbe_ReadMidStreamFails(t *testing.T) {
- fs := newFakeStorage()
- fs.readErr = errors.New("connection reset")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "read" {
- t.Fatalf("step = %v, want read (NOT verify); err = %v", pe, err)
- }
-}
-
-func TestStorageProbe_ContentMismatch(t *testing.T) {
- fs := newFakeStorage()
- fs.readOverride = []byte("wrong content")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "verify" {
- t.Fatalf("step = %v, want verify; err = %v", pe, err)
- }
-}
-
-func TestStorageProbe_DeleteFails(t *testing.T) {
- fs := newFakeStorage()
- fs.deleteErr = errors.New("permission denied")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "delete" {
- t.Fatalf("step = %v, want delete; err = %v", pe, err)
- }
-}
-
-// TestStorageProbe_CleanupOnNonDeleteFailure asserts that the probe object is
-// deleted even when a step after Store (size/open/read/verify) fails, so
-// probe artifacts don't accumulate in the storage backend.
-func TestStorageProbe_CleanupOnNonDeleteFailure(t *testing.T) {
- cases := []struct {
- name string
- inject func(*fakeStorage)
- wantErr string
- }{
- {"size mismatch", func(fs *fakeStorage) { fs.sizeDelta = -1 }, "size"},
- {"open fails", func(fs *fakeStorage) { fs.openErr = errors.New("open boom") }, "read"},
- {"read mid-stream", func(fs *fakeStorage) { fs.readErr = errors.New("mid-stream boom") }, "read"},
- {"content mismatch", func(fs *fakeStorage) { fs.readOverride = []byte("wrong") }, "verify"},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- fs := newFakeStorage()
- tc.inject(fs)
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != tc.wantErr {
- t.Fatalf("step = %v, want %q; err = %v", pe, tc.wantErr, err)
- }
- if got := fs.deleteCalls.Load(); got != 1 {
- t.Errorf("deleteCalls = %d, want 1 (cleanup should run on non-delete failures)", got)
- }
- })
- }
-}
-
-func TestStorageProbe_ReaderClosedOnReadFailure(t *testing.T) {
- fs := newFakeStorage()
- fs.readErr = errors.New("read error")
- _ = storageProbe(context.Background(), fs)
- if got := fs.closeCalls.Load(); got != fs.openCalls.Load() {
- t.Errorf("closeCalls = %d, openCalls = %d (should match)", got, fs.openCalls.Load())
- }
-}
-
-func TestStorageProbe_PathUniqueness(t *testing.T) {
- fs := newFakeStorage()
- for i := 0; i < 100; i++ {
- if err := storageProbe(context.Background(), fs); err != nil {
- t.Fatalf("probe %d: %v", i, err)
- }
- }
- seen := make(map[string]bool)
- for _, p := range fs.paths {
- if !strings.HasPrefix(p, ".healthcheck/") {
- t.Errorf("path missing prefix: %q", p)
- }
- if seen[p] {
- t.Errorf("duplicate path: %q", p)
- }
- seen[p] = true
- }
-}
-
-// helper: a healthCache wired to a fakeStorage and a discard logger.
-func newTestCache(fs *fakeStorage, interval time.Duration) *healthCache {
- return &healthCache{
- storage: fs,
- interval: interval,
- probeTimeout: 5 * time.Second,
- logger: discardLogger(),
- }
-}
-
-func discardLogger() *slog.Logger {
- return slog.New(slog.NewTextHandler(io.Discard, nil))
-}
-
-func TestHealthCache_CacheHit(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 30*time.Second)
- if err := c.Check(); err != nil {
- t.Fatalf("first check: %v", err)
- }
- if err := c.Check(); err != nil {
- t.Fatalf("second check: %v", err)
- }
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("storeCalls = %d, want 1 (second call should be cached)", got)
- }
-}
-
-func TestHealthCache_MissAfterTTL(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 10*time.Millisecond)
- _ = c.Check()
- time.Sleep(20 * time.Millisecond)
- _ = c.Check()
- if got := fs.storeCalls.Load(); got != 2 {
- t.Errorf("storeCalls = %d, want 2", got)
- }
-}
-
-func TestHealthCache_Disabled(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 0) // interval = 0 means probe every call
- _ = c.Check()
- _ = c.Check()
- if got := fs.storeCalls.Load(); got != 2 {
- t.Errorf("storeCalls = %d, want 2", got)
- }
-}
-
-func TestHealthCache_LastAtNotAdvancedOnHit(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 30*time.Second)
- for i := 0; i < 100; i++ {
- _ = c.Check()
- }
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("storeCalls = %d, want 1 across 100 hits", got)
- }
-}
-
-func TestHealthCache_ConcurrentSingleFlight(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 30*time.Second)
- var wg sync.WaitGroup
- for i := 0; i < 20; i++ {
- wg.Add(1)
- go func() { defer wg.Done(); _ = c.Check() }()
- }
- wg.Wait()
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("storeCalls = %d, want 1 with 20 concurrent callers", got)
- }
-}
-
-func TestHealthCache_FailureCounterIncrement(t *testing.T) {
- fs := newFakeStorage()
- fs.storeErr = errors.New("boom")
- c := newTestCache(fs, 30*time.Second)
-
- before := testutil.ToFloat64(metrics.HealthProbeFailures.WithLabelValues("write"))
-
- // First call: fresh probe → counter +1
- _ = c.Check()
- afterFirst := testutil.ToFloat64(metrics.HealthProbeFailures.WithLabelValues("write"))
- if afterFirst-before != 1 {
- t.Errorf("counter delta after first call = %v, want 1", afterFirst-before)
- }
-
- // Second call: cache hit → counter NOT re-incremented
- _ = c.Check()
- afterSecond := testutil.ToFloat64(metrics.HealthProbeFailures.WithLabelValues("write"))
- if afterSecond != afterFirst {
- t.Errorf("counter changed on cache hit: %v → %v", afterFirst, afterSecond)
- }
-}
-
-func TestHealthCache_ProbeTimeout(t *testing.T) {
- fs := newFakeStorage()
- fs.storeBlock = make(chan struct{}) // Store will block until channel is closed (or never)
- t.Cleanup(func() { close(fs.storeBlock) })
-
- c := &healthCache{
- storage: fs,
- interval: 30 * time.Second,
- probeTimeout: 50 * time.Millisecond,
- logger: discardLogger(),
- }
- start := time.Now()
- err := c.Check()
- elapsed := time.Since(start)
-
- if err == nil {
- t.Fatal("expected timeout error, got nil")
- }
- if elapsed > 500*time.Millisecond {
- t.Errorf("probe took %v, expected ~50ms (timeout not respected)", elapsed)
- }
-}
-
-func TestHealthCache_TransitionLogging(t *testing.T) {
- fs := newFakeStorage()
- var buf bytes.Buffer
- logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
- c := &healthCache{
- storage: fs,
- interval: 0, // probe every call
- probeTimeout: 5 * time.Second,
- logger: logger,
- }
-
- // Steady ok state — should not log
- _ = c.Check()
- _ = c.Check()
- if got := strings.Count(buf.String(), "storage probe"); got != 0 {
- t.Errorf("steady-state logs = %d, want 0; output: %s", got, buf.String())
- }
-
- // ok → err transition: exactly one Error log
- buf.Reset()
- fs.storeErr = errors.New("boom")
- _ = c.Check()
- if !strings.Contains(buf.String(), "storage probe failed") {
- t.Errorf("missing failure log on transition; output: %s", buf.String())
- }
-
- // err steady state — should not log again
- buf.Reset()
- _ = c.Check()
- if buf.Len() != 0 {
- t.Errorf("steady-err logs = %q, want empty", buf.String())
- }
-
- // err → ok transition: exactly one Info log
- buf.Reset()
- fs.storeErr = nil
- _ = c.Check()
- if !strings.Contains(buf.String(), "storage probe recovered") {
- t.Errorf("missing recovery log on transition; output: %s", buf.String())
- }
-}
diff --git a/internal/server/server.go b/internal/server/server.go
index 7de5041..7274365 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -41,7 +41,6 @@ import (
"context"
"database/sql"
"encoding/json"
- "errors"
"fmt"
"log/slog"
"net/http"
@@ -51,7 +50,7 @@ import (
swaggerdoc "github.com/git-pkgs/proxy/docs/swagger"
"github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/enrichment"
"github.com/git-pkgs/proxy/internal/handler"
@@ -81,8 +80,7 @@ type Server struct {
logger *slog.Logger
http *http.Server
templates *Templates
- cancel context.CancelFunc
- healthCache *healthCache
+ cancel context.CancelFunc
}
// New creates a new Server with the given configuration.
@@ -128,20 +126,12 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
return nil, fmt.Errorf("verifying storage connectivity: %w", err)
}
- hc, err := newHealthCache(store, cfg.Health.StorageProbeInterval, logger)
- if err != nil {
- _ = store.Close()
- _ = db.Close()
- return nil, fmt.Errorf("initializing health cache: %w", err)
- }
-
return &Server{
- cfg: cfg,
- db: db,
- storage: store,
- logger: logger,
- templates: &Templates{},
- healthCache: hc,
+ cfg: cfg,
+ db: db,
+ storage: store,
+ logger: logger,
+ templates: &Templates{},
}, nil
}
@@ -160,7 +150,6 @@ func (s *Server) Start() error {
proxy.Cooldown = cd
proxy.CacheMetadata = s.cfg.CacheMetadata
proxy.MetadataTTL = s.cfg.ParseMetadataTTL()
- proxy.MetadataMaxSize = s.cfg.ParseMetadataMaxSize()
proxy.GradleReadOnly = s.cfg.Gradle.BuildCache.ReadOnly
proxy.GradleMaxUploadSize = s.cfg.ParseGradleBuildCacheMaxUploadSize()
proxy.DirectServe = s.cfg.Storage.DirectServe
@@ -194,12 +183,7 @@ func (s *Server) Start() error {
hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL)
pubHandler := handler.NewPubHandler(proxy, s.cfg.BaseURL)
pypiHandler := handler.NewPyPIHandler(proxy, s.cfg.BaseURL)
- mavenHandler := handler.NewMavenHandler(
- proxy,
- s.cfg.BaseURL,
- s.cfg.Upstream.Maven,
- s.cfg.Upstream.GradlePluginPortal,
- )
+ mavenHandler := handler.NewMavenHandler(proxy, s.cfg.BaseURL)
gradleHandler := handler.NewGradleBuildCacheHandler(proxy)
nugetHandler := handler.NewNuGetHandler(proxy, s.cfg.BaseURL)
composerHandler := handler.NewComposerHandler(proxy, s.cfg.BaseURL)
@@ -818,49 +802,23 @@ func (s *Server) showComparePage(w http.ResponseWriter, ecosystem, name, version
}
}
-// handleHealth responds with a structured JSON health report.
-//
+// handleHealth responds with a simple health check.
// @Summary Health check
// @Tags meta
-// @Produce json
-// @Success 200 {object} HealthResponse
-// @Failure 503 {object} HealthResponse
+// @Produce plain
+// @Success 200 {string} string
+// @Failure 503 {string} string
// @Router /health [get]
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
-
- resp := HealthResponse{Status: "ok", Checks: map[string]HealthCheck{}}
-
- // Database check (short-circuit; do not waste a storage probe call when DB is down).
- // On DB failure the storage entry reports "skipped" rather than being omitted so
- // the response always carries the same key set for monitors that expect it.
+ // Check database connectivity
if _, err := s.db.SchemaVersion(); err != nil {
- resp.Status = "error"
- resp.Checks["database"] = HealthCheck{Status: "error", Error: err.Error()}
- resp.Checks["storage"] = HealthCheck{Status: "skipped"}
w.WriteHeader(http.StatusServiceUnavailable)
- _ = json.NewEncoder(w).Encode(resp)
+ _, _ = fmt.Fprint(w, "database error")
return
}
- resp.Checks["database"] = HealthCheck{Status: "ok"}
-
- // Storage probe (via cache).
- if err := s.healthCache.Check(); err != nil {
- resp.Status = "error"
- sc := HealthCheck{Status: "error", Error: err.Error()}
- var pe *probeError
- if errors.As(err, &pe) {
- sc.Step = pe.step
- }
- resp.Checks["storage"] = sc
- w.WriteHeader(http.StatusServiceUnavailable)
- _ = json.NewEncoder(w).Encode(resp)
- return
- }
- resp.Checks["storage"] = HealthCheck{Status: "ok"}
w.WriteHeader(http.StatusOK)
- _ = json.NewEncoder(w).Encode(resp)
+ _, _ = fmt.Fprint(w, "ok")
}
// StatsResponse contains cache statistics.
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index e2dc1c2..574b6ba 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -81,21 +81,13 @@ func newTestServer(t *testing.T) *testServer {
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes()))
- hc, err := newHealthCache(store, "30s", logger)
- if err != nil {
- _ = db.Close()
- _ = os.RemoveAll(tempDir)
- t.Fatalf("failed to create health cache: %v", err)
- }
-
// Create a minimal server struct for the handlers
s := &Server{
- cfg: cfg,
- db: db,
- storage: store,
- logger: logger,
- templates: &Templates{},
- healthCache: hc,
+ cfg: cfg,
+ db: db,
+ storage: store,
+ logger: logger,
+ templates: &Templates{},
}
r.Get("/health", s.handleHealth)
@@ -187,55 +179,12 @@ func TestHealthEndpoint(t *testing.T) {
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
+ t.Errorf("expected status 200, got %d", w.Code)
}
- if got := w.Header().Get("Content-Type"); got != "application/json" {
- t.Errorf("Content-Type = %q, want application/json", got)
- }
- var resp HealthResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding response: %v", err)
- }
- if resp.Status != "ok" {
- t.Errorf("status = %q, want ok", resp.Status)
- }
- if resp.Checks["database"].Status != "ok" {
- t.Errorf("database check = %+v, want ok", resp.Checks["database"])
- }
- if resp.Checks["storage"].Status != "ok" {
- t.Errorf("storage check = %+v, want ok", resp.Checks["storage"])
- }
-}
-func TestHealthEndpoint_DBFailureShortCircuits(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- // Force DB failure by closing the connection.
- _ = ts.db.Close()
-
- req := httptest.NewRequest("GET", "/health", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusServiceUnavailable {
- t.Fatalf("status = %d, want 503; body: %s", w.Code, w.Body.String())
- }
- var resp HealthResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding: %v", err)
- }
- if resp.Status != "error" {
- t.Errorf("status = %q, want error", resp.Status)
- }
- if resp.Checks["database"].Status != "error" {
- t.Errorf("database check = %+v, want error", resp.Checks["database"])
- }
- storage, present := resp.Checks["storage"]
- if !present {
- t.Error("storage key should be present (with status=skipped) on DB short-circuit")
- } else if storage.Status != "skipped" {
- t.Errorf("storage check = %+v, want status=skipped", storage)
+ body := w.Body.String()
+ if body != "ok" {
+ t.Errorf("expected body 'ok', got %q", body)
}
}