diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index e0d3cfb..82ee83c 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@bcafcacb16a39f128d818304e6c9c0c18556b85f
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
with:
context: .
push: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c5f2ebf..c23de56 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@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
+ - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- 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@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1
+ - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: "~> v2"
args: release --clean
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
index 4acd19b..f621254 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@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
+ uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
diff --git a/Dockerfile b/Dockerfile
index 71a9fcc..5124b1d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.25-alpine AS builder
+FROM golang:1.26.3-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.21
+FROM alpine:3.23.4
RUN apk add --no-cache ca-certificates
diff --git a/README.md b/README.md
index 22beaaf..abf3e11 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,7 @@ 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 | | ✓ |
@@ -209,6 +210,18 @@ 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)`:
@@ -312,6 +325,21 @@ 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`:
@@ -370,6 +398,7 @@ 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)
@@ -577,7 +606,7 @@ Recently cached:
| Endpoint | Description |
|----------|-------------|
| `GET /` | Dashboard (web UI) |
-| `GET /health` | Health check (returns "ok" if healthy) |
+| `GET /health` | Health check (JSON; HTTP 200 healthy, 503 unhealthy) |
| `GET /stats` | Cache statistics (JSON) |
| `GET /metrics` | Prometheus metrics |
| `GET /npm/*` | npm registry protocol |
@@ -593,6 +622,7 @@ 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 |
@@ -815,9 +845,28 @@ 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
@@ -941,6 +990,7 @@ 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 946d12a..15a71c0 100644
--- a/cmd/proxy/main.go
+++ b/cmd/proxy/main.go
@@ -72,11 +72,14 @@
// 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:
//
@@ -198,11 +201,14 @@ 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:])
@@ -464,6 +470,7 @@ 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 4505849..11c751c 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -71,6 +71,12 @@ 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"
@@ -128,6 +134,15 @@ 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 81c41cf..85e5aaf 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 │ │
-│ │ ...16 ecosystems /api/* -> APIHandler │ │
+│ │ ...17 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, 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.
### `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.
-### `internal/cooldown`
+### Cooldown
-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.
+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.
### `internal/enrichment`
diff --git a/docs/configuration.md b/docs/configuration.md
index ac85d54..1310bd0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -114,6 +114,8 @@ 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"
```
@@ -263,6 +265,16 @@ 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 34aedbf..23ff54a 100644
--- a/docs/swagger/docs.go
+++ b/docs/swagger/docs.go
@@ -399,7 +399,7 @@ const docTemplate = `{
"/health": {
"get": {
"produces": [
- "text/plain"
+ "application/json"
],
"tags": [
"meta"
@@ -409,13 +409,13 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "type": "string"
+ "$ref": "#/definitions/server.HealthResponse"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
- "type": "string"
+ "$ref": "#/definitions/server.HealthResponse"
}
}
}
@@ -515,6 +515,34 @@ 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 b775e63..c2b4dfc 100644
--- a/docs/swagger/swagger.json
+++ b/docs/swagger/swagger.json
@@ -392,7 +392,7 @@
"/health": {
"get": {
"produces": [
- "text/plain"
+ "application/json"
],
"tags": [
"meta"
@@ -402,13 +402,13 @@
"200": {
"description": "OK",
"schema": {
- "type": "string"
+ "$ref": "#/definitions/server.HealthResponse"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
- "type": "string"
+ "$ref": "#/definitions/server.HealthResponse"
}
}
}
@@ -508,6 +508,34 @@
}
}
},
+ "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 87fd2db..199c8c8 100644
--- a/go.mod
+++ b/go.mod
@@ -3,13 +3,15 @@ module github.com/git-pkgs/proxy
go 1.25.6
require (
- github.com/CycloneDX/cyclonedx-go v0.10.0
+ github.com/BurntSushi/toml v1.6.0
+ github.com/CycloneDX/cyclonedx-go v0.11.0
github.com/git-pkgs/archives v0.3.0
- github.com/git-pkgs/enrichment v0.2.2
+ github.com/git-pkgs/cooldown v0.1.1
+ github.com/git-pkgs/enrichment v0.2.3
github.com/git-pkgs/purl v0.1.12
- 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/registries v0.6.1
+ github.com/git-pkgs/spdx v0.1.4
+ github.com/git-pkgs/vers v0.2.6
github.com/git-pkgs/vulns v0.1.5
github.com/go-chi/chi/v5 v5.2.5
github.com/jmoiron/sqlx v1.4.0
@@ -22,7 +24,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.0
+ modernc.org/sqlite v1.50.1
)
require (
@@ -49,7 +51,6 @@ 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
@@ -128,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.6.0 // indirect
+ github.com/github/go-spdx/v2 v2.7.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
@@ -309,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.0 // indirect
+ modernc.org/libc v1.72.3 // 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 80df597..23c3df7 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.10.0 h1:7xyklU7YD+CUyGzSFIARG18NYLsKVn4QFg04qSsu+7Y=
-github.com/CycloneDX/cyclonedx-go v0.10.0/go.mod h1:vUvbCXQsEm48OI6oOlanxstwNByXjCZ2wuleUlwGEO8=
+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/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,24 +252,26 @@ 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/enrichment v0.2.2 h1:vaQu5vs3tjQB5JI0gzBrUCynUc9z3l5byPhgKFaNZrc=
-github.com/git-pkgs/enrichment v0.2.2/go.mod h1:5JWGmlHWcv5HQHUrctcpnRUNpEF5VAixD2z4zvqKejs=
+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/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.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/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/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.6.0 h1:Y/Chr7L8oG85Ilbzl11xkUSQFUfG1kGkLP18LyInvhg=
-github.com/github/go-spdx/v2 v2.6.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0=
+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/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=
@@ -882,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.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/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/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=
@@ -894,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.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
-modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
+modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
+modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
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.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
-modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/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.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
-modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
+modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
+modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
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 067981d..0e8405d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -96,12 +96,20 @@ 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.
@@ -182,6 +190,14 @@ 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".
@@ -210,6 +226,15 @@ 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"`
@@ -287,9 +312,11 @@ func Default() *Config {
Format: "text",
},
Upstream: UpstreamConfig{
- NPM: "https://registry.npmjs.org",
- Cargo: "https://index.crates.io",
- CargoDownload: "https://static.crates.io/crates",
+ 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",
},
Gradle: GradleConfig{
BuildCache: GradleBuildCacheConfig{
@@ -343,6 +370,7 @@ 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
@@ -383,6 +411,12 @@ 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
}
@@ -395,6 +429,9 @@ 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"
}
@@ -410,6 +447,9 @@ 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.
@@ -481,6 +521,14 @@ 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
}
@@ -488,6 +536,22 @@ 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 {
@@ -530,6 +594,7 @@ 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"
@@ -549,6 +614,33 @@ 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 26a0fc6..d633c25 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -31,6 +31,12 @@ 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) {
@@ -264,6 +270,8 @@ 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")
@@ -284,6 +292,12 @@ 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")
}
@@ -414,6 +428,52 @@ 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"
@@ -432,6 +492,34 @@ 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
deleted file mode 100644
index f37a2b9..0000000
--- a/internal/cooldown/cooldown.go
+++ /dev/null
@@ -1,125 +0,0 @@
-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
deleted file mode 100644
index c366077..0000000
--- a/internal/cooldown/cooldown_test.go
+++ /dev/null
@@ -1,133 +0,0 @@
-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 5ce81b6..10d3faf 100644
--- a/internal/handler/cargo_test.go
+++ b/internal/handler/cargo_test.go
@@ -9,7 +9,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/proxy/internal/cooldown"
+ "github.com/git-pkgs/cooldown"
)
func cargoTestProxy() *Proxy {
diff --git a/internal/handler/composer.go b/internal/handler/composer.go
index 0933ece..065ddf9 100644
--- a/internal/handler/composer.go
+++ b/internal/handler/composer.go
@@ -16,6 +16,7 @@ import (
const (
composerUpstream = "https://packagist.org"
composerRepo = "https://repo.packagist.org"
+ composerUnset = "__unset"
vendorPackageParts = 2
)
@@ -150,7 +151,8 @@ 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.
+// The "~dev" sentinel string resets the inheritance chain, and the "__unset"
+// value removes a field from the inherited state.
func expandMinifiedVersions(versionList []any) []any {
expanded := make([]any, 0, len(versionList))
inherited := map[string]any{}
@@ -174,6 +176,10 @@ 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 94ff8cb..baf13b6 100644
--- a/internal/handler/composer_test.go
+++ b/internal/handler/composer_test.go
@@ -7,7 +7,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/proxy/internal/cooldown"
+ "github.com/git-pkgs/cooldown"
)
func TestComposerRewriteMetadata(t *testing.T) {
@@ -177,6 +177,80 @@ 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 1336f94..cfa20c8 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 := ReadMetadata(resp.Body)
+ body, err := h.proxy.ReadMetadata(resp.Body)
if err != nil {
http.Error(w, "failed to read response", http.StatusInternalServerError)
return
diff --git a/internal/handler/conda_test.go b/internal/handler/conda_test.go
index 24b0236..1b57039 100644
--- a/internal/handler/conda_test.go
+++ b/internal/handler/conda_test.go
@@ -8,7 +8,7 @@ import (
"testing"
"time"
- "github.com/git-pkgs/proxy/internal/cooldown"
+ "github.com/git-pkgs/cooldown"
)
func TestCondaParseFilename(t *testing.T) {
diff --git a/internal/handler/download_test.go b/internal/handler/download_test.go
index 639e976..980e234 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"}
+ extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib", ".module"}
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,6 +809,274 @@ 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
new file mode 100644
index 0000000..f4e4847
--- /dev/null
+++ b/internal/server/health.go
@@ -0,0 +1,182 @@
+// 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
new file mode 100644
index 0000000..c0f70c9
--- /dev/null
+++ b/internal/server/health_test.go
@@ -0,0 +1,448 @@
+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 a0983e5..7de5041 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -15,6 +15,7 @@
// - /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
@@ -40,6 +41,7 @@ import (
"context"
"database/sql"
"encoding/json"
+ "errors"
"fmt"
"log/slog"
"net/http"
@@ -49,7 +51,7 @@ import (
swaggerdoc "github.com/git-pkgs/proxy/docs/swagger"
"github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/cooldown"
+ "github.com/git-pkgs/cooldown"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/enrichment"
"github.com/git-pkgs/proxy/internal/handler"
@@ -79,7 +81,8 @@ type Server struct {
logger *slog.Logger
http *http.Server
templates *Templates
- cancel context.CancelFunc
+ cancel context.CancelFunc
+ healthCache *healthCache
}
// New creates a new Server with the given configuration.
@@ -125,12 +128,20 @@ 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{},
+ cfg: cfg,
+ db: db,
+ storage: store,
+ logger: logger,
+ templates: &Templates{},
+ healthCache: hc,
}, nil
}
@@ -149,6 +160,7 @@ 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
@@ -182,13 +194,19 @@ 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)
+ mavenHandler := handler.NewMavenHandler(
+ proxy,
+ s.cfg.BaseURL,
+ s.cfg.Upstream.Maven,
+ s.cfg.Upstream.GradlePluginPortal,
+ )
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)
@@ -207,6 +225,7 @@ 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()))
@@ -799,23 +818,49 @@ func (s *Server) showComparePage(w http.ResponseWriter, ecosystem, name, version
}
}
-// handleHealth responds with a simple health check.
+// handleHealth responds with a structured JSON health report.
+//
// @Summary Health check
// @Tags meta
-// @Produce plain
-// @Success 200 {string} string
-// @Failure 503 {string} string
+// @Produce json
+// @Success 200 {object} HealthResponse
+// @Failure 503 {object} HealthResponse
// @Router /health [get]
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
- // Check database connectivity
+ 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.
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)
- _, _ = fmt.Fprint(w, "database error")
+ _ = json.NewEncoder(w).Encode(resp)
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)
- _, _ = fmt.Fprint(w, "ok")
+ _ = json.NewEncoder(w).Encode(resp)
}
// StatsResponse contains cache statistics.
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index 574b6ba..e2dc1c2 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -81,13 +81,21 @@ 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{},
+ cfg: cfg,
+ db: db,
+ storage: store,
+ logger: logger,
+ templates: &Templates{},
+ healthCache: hc,
}
r.Get("/health", s.handleHealth)
@@ -179,12 +187,55 @@ func TestHealthEndpoint(t *testing.T) {
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Errorf("expected status 200, got %d", w.Code)
+ t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
+ 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"])
+ }
+}
- body := w.Body.String()
- if body != "ok" {
- t.Errorf("expected body 'ok', got %q", body)
+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)
}
}