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..cc79d4a 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@e24998b8b67b290c2fa8b7c14fcfa7de2c5c9b8c # v7.1.0
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 a8c8bdf..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:])
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..81c41cf 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,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 cf6c101..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"
```
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..87fd2db 100644
--- a/go.mod
+++ b/go.mod
@@ -3,15 +3,13 @@ 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.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 +22,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 +49,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 +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 87e23ac..067981d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -102,9 +102,6 @@ type Config struct {
// 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.
@@ -185,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".
@@ -221,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"`
@@ -307,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{
@@ -365,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
@@ -406,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
}
@@ -439,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.
@@ -513,10 +481,6 @@ func (c *Config) Validate() error {
}
}
- if err := c.Health.Validate(); err != nil {
- return err
- }
-
if err := c.Gradle.BuildCache.Validate(); err != nil {
return err
}
@@ -524,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 {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 05fec3a..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")
}
@@ -446,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_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("
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 251386e..a0983e5 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"
@@ -51,7 +49,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 +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
}
@@ -193,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)
@@ -224,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()))
@@ -817,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)
}
}