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..22beaaf 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,6 @@ Resolution order: package override, then ecosystem override, then global default
| Conan | C/C++ | | ✓ |
| Conda | Python/R | Yes | ✓ |
| CRAN | R | | ✓ |
-| Julia | Julia | | ✓ |
| Container | Docker/OCI | | ✓ |
| Debian | Debian/Ubuntu | | ✓ |
| RPM | RHEL/Fedora | | ✓ |
@@ -210,18 +209,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)`:
@@ -325,21 +312,6 @@ local({
})
```
-### Julia
-
-Set the Pkg server before starting Julia:
-
-```bash
-export JULIA_PKG_SERVER=http://localhost:8080/julia
-```
-
-Or inside a running session:
-
-```julia
-ENV["JULIA_PKG_SERVER"] = "http://localhost:8080/julia"
-using Pkg; Pkg.update()
-```
-
### Docker / Container Registry
Configure Docker to use the proxy as a registry mirror in `/etc/docker/daemon.json`:
@@ -398,7 +370,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 +577,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 |
@@ -622,7 +593,6 @@ Recently cached:
| `GET /conan/*` | Conan C/C++ protocol |
| `GET /conda/*` | Conda/Anaconda protocol |
| `GET /cran/*` | CRAN (R) protocol |
-| `GET /julia/*` | Julia Pkg server protocol |
| `GET /v2/*` | OCI/Docker registry protocol |
| `GET /debian/*` | Debian/APT repository protocol |
| `GET /rpm/*` | RPM/Yum repository protocol |
@@ -845,28 +815,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 +941,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..85677b6 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -14,7 +14,7 @@ The proxy is a caching HTTP server that sits between package manager clients and
│ │ /npm/* -> NPMHandler /health -> healthHandler │ │
│ │ /cargo/* -> CargoHandler /stats -> statsHandler │ │
│ │ /gem/* -> GemHandler /metrics -> prometheus │ │
-│ │ ...17 ecosystems /api/* -> APIHandler │ │
+│ │ ...16 ecosystems /api/* -> APIHandler │ │
│ │ / -> Web UI │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │ │ │
@@ -277,7 +277,7 @@ 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`
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..02e6b41 100644
--- a/go.mod
+++ b/go.mod
@@ -3,15 +3,14 @@ module github.com/git-pkgs/proxy
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.6.0
+ 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 (
@@ -51,6 +50,7 @@ require (
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
+ github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
@@ -129,7 +129,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 +310,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..5fe9699 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=
@@ -254,24 +254,24 @@ github.com/git-pkgs/archives v0.3.0 h1:iXKyO83jEFub1PGEDlHmk2tQ7XeV5LySTc0sEkH3x
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.6.0 h1:ttQC8via9XAoLk9vqysf0K7uWl1bAyHPBWRBavRpAqs=
+github.com/git-pkgs/registries v0.6.0/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 +884,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 +896,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/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..e4d79af 100644
--- a/internal/handler/composer_test.go
+++ b/internal/handler/composer_test.go
@@ -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/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("
Set the Pkg server before starting Julia:
-export JULIA_PKG_SERVER=` + baseURL + `/julia
-Or inside a running session:
-ENV["JULIA_PKG_SERVER"] = "` + baseURL + `/julia"
-using Pkg; Pkg.update()`),
},
{
ID: "oci",
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..cd57ae3 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -15,7 +15,6 @@
// - /conan/* - Conan C/C++ protocol
// - /conda/* - Conda/Anaconda protocol
// - /cran/* - CRAN (R) protocol
-// - /julia/* - Julia Pkg server protocol
// - /v2/* - OCI/Docker container registry protocol
// - /debian/* - Debian/APT repository protocol
// - /rpm/* - RPM/Yum repository protocol
@@ -41,7 +40,6 @@ import (
"context"
"database/sql"
"encoding/json"
- "errors"
"fmt"
"log/slog"
"net/http"
@@ -81,8 +79,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 +125,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 +149,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,19 +182,13 @@ 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)
conanHandler := handler.NewConanHandler(proxy, s.cfg.BaseURL)
condaHandler := handler.NewCondaHandler(proxy, s.cfg.BaseURL)
cranHandler := handler.NewCRANHandler(proxy, s.cfg.BaseURL)
- juliaHandler := handler.NewJuliaHandler(proxy, s.cfg.BaseURL)
containerHandler := handler.NewContainerHandler(proxy, s.cfg.BaseURL)
debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL)
rpmHandler := handler.NewRPMHandler(proxy, s.cfg.BaseURL)
@@ -225,7 +207,6 @@ func (s *Server) Start() error {
r.Mount("/conan", http.StripPrefix("/conan", conanHandler.Routes()))
r.Mount("/conda", http.StripPrefix("/conda", condaHandler.Routes()))
r.Mount("/cran", http.StripPrefix("/cran", cranHandler.Routes()))
- r.Mount("/julia", http.StripPrefix("/julia", juliaHandler.Routes()))
r.Mount("/v2", http.StripPrefix("/v2", containerHandler.Routes()))
r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes()))
r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes()))
@@ -818,49 +799,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)
}
}