mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-06-02 00:38:16 -04:00
Compare commits
1 commit
main
...
julia-supp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1888626290 |
39 changed files with 365 additions and 1597 deletions
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||
- uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
|
|
|
|||
2
.github/workflows/zizmor.yml
vendored
2
.github/workflows/zizmor.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -210,18 +210,6 @@ Add to your `~/.m2/settings.xml`:
|
|||
</settings>
|
||||
```
|
||||
|
||||
The `/maven/` endpoint uses Maven Central as primary upstream and falls back to the Gradle Plugin Portal for Gradle plugin marker metadata and related artifacts when the primary upstream returns not found.
|
||||
|
||||
For Gradle plugin resolution via the same proxy endpoint:
|
||||
|
||||
```kotlin
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven(url = "http://localhost:8080/maven/")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gradle HTTP Build Cache
|
||||
|
||||
Configure in `settings.gradle(.kts)`:
|
||||
|
|
@ -398,7 +386,6 @@ sudo dnf update
|
|||
## Configuration
|
||||
|
||||
The proxy can be configured via:
|
||||
|
||||
1. Command line flags (highest priority)
|
||||
2. Environment variables
|
||||
3. Configuration file (YAML or JSON)
|
||||
|
|
@ -606,7 +593,7 @@ Recently cached:
|
|||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /` | Dashboard (web UI) |
|
||||
| `GET /health` | Health check (JSON; HTTP 200 healthy, 503 unhealthy) |
|
||||
| `GET /health` | Health check (returns "ok" if healthy) |
|
||||
| `GET /stats` | Cache statistics (JSON) |
|
||||
| `GET /metrics` | Prometheus metrics |
|
||||
| `GET /npm/*` | npm registry protocol |
|
||||
|
|
@ -845,28 +832,9 @@ The proxy exposes Prometheus metrics at `GET /metrics`. All metric names are pre
|
|||
| `proxy_storage_operation_duration_seconds` | histogram | `operation` | Storage read/write latency |
|
||||
| `proxy_storage_errors_total` | counter | `operation` | Storage read/write failures |
|
||||
| `proxy_active_requests` | gauge | | In-flight requests |
|
||||
| `proxy_health_probe_failures_total` | counter | `step` | Storage health probe failures by failing step (`write`, `size`, `read`, `verify`, `delete`). |
|
||||
|
||||
Cache size and artifact count are refreshed every 60 seconds. The remaining metrics update on each request.
|
||||
|
||||
### Health Check
|
||||
|
||||
`/health` returns a structured JSON report of subsystem health. HTTP 200 if all checks pass; 503 if any fail.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"checks": {
|
||||
"database": {"status": "ok"},
|
||||
"storage": {"status": "ok"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Failing checks include an `"error"` field. Storage failures also include a `"step"` field identifying which probe step failed (`write`, `size`, `read`, `verify`, `delete`). When the database check fails, the storage entry reports `{"status": "skipped"}` so the response always carries the same key set.
|
||||
|
||||
Storage probe results are cached for `health.storage_probe_interval` (default 30s) to bound the cost of probing remote backends. A probe holds an internal mutex for up to 10 seconds (the hardcoded per-probe timeout), so `/health` is intended as a Kubernetes **readiness** probe rather than a liveness probe — a slow S3 round-trip should pull the pod from rotation, not restart it.
|
||||
|
||||
Scrape config for Prometheus:
|
||||
|
||||
```yaml
|
||||
|
|
@ -990,7 +958,6 @@ The proxy will recreate the database on next start.
|
|||
## Building from Source
|
||||
|
||||
Requirements:
|
||||
|
||||
- Go 1.25 or later
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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:])
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
17
go.mod
17
go.mod
|
|
@ -4,14 +4,13 @@ go 1.25.6
|
|||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/CycloneDX/cyclonedx-go v0.11.0
|
||||
github.com/CycloneDX/cyclonedx-go v0.10.0
|
||||
github.com/git-pkgs/archives v0.3.0
|
||||
github.com/git-pkgs/cooldown v0.1.1
|
||||
github.com/git-pkgs/enrichment v0.2.3
|
||||
github.com/git-pkgs/enrichment v0.2.2
|
||||
github.com/git-pkgs/purl v0.1.12
|
||||
github.com/git-pkgs/registries v0.6.1
|
||||
github.com/git-pkgs/spdx v0.1.4
|
||||
github.com/git-pkgs/vers v0.2.6
|
||||
github.com/git-pkgs/registries v0.5.1
|
||||
github.com/git-pkgs/spdx v0.1.3
|
||||
github.com/git-pkgs/vers v0.2.5
|
||||
github.com/git-pkgs/vulns v0.1.5
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
|
|
@ -24,7 +23,7 @@ require (
|
|||
golang.org/x/sync v0.20.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.50.1
|
||||
modernc.org/sqlite v1.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -129,7 +128,7 @@ require (
|
|||
github.com/ghostiam/protogetter v0.3.20 // indirect
|
||||
github.com/git-pkgs/packageurl-go v0.3.1 // indirect
|
||||
github.com/git-pkgs/pom v0.1.4 // indirect
|
||||
github.com/github/go-spdx/v2 v2.7.0 // indirect
|
||||
github.com/github/go-spdx/v2 v2.6.0 // indirect
|
||||
github.com/go-critic/go-critic v0.14.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
|
|
@ -310,7 +309,7 @@ require (
|
|||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
|
|
|
|||
46
go.sum
46
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=
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
125
internal/cooldown/cooldown.go
Normal file
125
internal/cooldown/cooldown.go
Normal file
|
|
@ -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
|
||||
}
|
||||
133
internal/cooldown/cooldown_test.go
Normal file
133
internal/cooldown/cooldown_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/cooldown"
|
||||
)
|
||||
|
||||
func cargoTestProxy() *Proxy {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/cooldown"
|
||||
)
|
||||
|
||||
func TestCondaParseFilename(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -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("<project/>")),
|
||||
ContentType: "application/xml",
|
||||
}
|
||||
|
||||
h := NewMavenHandler(proxy, "http://localhost", primaryUpstream, pluginPortalUpstream)
|
||||
srv := httptest.NewServer(h.Routes())
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + tt.markerPath)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if string(body) != "<project/>" {
|
||||
t.Fatalf("body = %q, want %q", body, "<project/>")
|
||||
}
|
||||
|
||||
wantFallbackURL := pluginPortalUpstream + tt.markerPath
|
||||
if fetcher.fetchedURL != wantFallbackURL {
|
||||
t.Fatalf("fallback URL = %q, want %q", fetcher.fetchedURL, wantFallbackURL)
|
||||
}
|
||||
|
||||
fetcher.fetchCalled = false
|
||||
resp, err = http.Get(srv.URL + tt.markerPath)
|
||||
if err != nil {
|
||||
t.Fatalf("second request failed: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if fetcher.fetchCalled {
|
||||
t.Fatal("expected plugin marker POM to be served from cache on second request")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMavenHandler_GradlePluginMarkerMetadataFallback(t *testing.T) {
|
||||
paths := map[string]string{
|
||||
"/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom.sha1": "sha1",
|
||||
"/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom.sha256": "sha256",
|
||||
"/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom.md5": "md5",
|
||||
"/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/maven-metadata.xml": "<metadata/>",
|
||||
}
|
||||
|
||||
primaryHits := map[string]int{}
|
||||
pluginHits := map[string]int{}
|
||||
|
||||
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
primaryHits[r.URL.Path]++
|
||||
if _, ok := paths[r.URL.Path]; ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected path to primary upstream: %s", r.URL.Path)
|
||||
}))
|
||||
defer primary.Close()
|
||||
|
||||
pluginPortal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
pluginHits[r.URL.Path]++
|
||||
body, ok := paths[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
defer pluginPortal.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
proxy.HTTPClient = primary.Client()
|
||||
|
||||
h := NewMavenHandler(proxy, "http://localhost", primary.URL, pluginPortal.URL)
|
||||
srv := httptest.NewServer(h.Routes())
|
||||
defer srv.Close()
|
||||
|
||||
for reqPath, wantBody := range paths {
|
||||
resp, err := http.Get(srv.URL + reqPath)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s failed: %v", reqPath, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s: status = %d, want %d", reqPath, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if string(body) != wantBody {
|
||||
t.Fatalf("GET %s: body = %q, want %q", reqPath, body, wantBody)
|
||||
}
|
||||
|
||||
if primaryHits[reqPath] == 0 {
|
||||
t.Fatalf("GET %s did not hit primary upstream", reqPath)
|
||||
}
|
||||
if pluginHits[reqPath] == 0 {
|
||||
t.Fatalf("GET %s did not hit plugin portal fallback", reqPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMavenHandler_GradlePluginImplementationMetadataFallback(t *testing.T) {
|
||||
paths := map[string]string{
|
||||
"/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar.sha1": "impl-sha1",
|
||||
"/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar.sha256": "impl-sha256",
|
||||
}
|
||||
|
||||
primaryHits := map[string]int{}
|
||||
pluginHits := map[string]int{}
|
||||
|
||||
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
primaryHits[r.URL.Path]++
|
||||
if _, ok := paths[r.URL.Path]; ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected path to primary upstream: %s", r.URL.Path)
|
||||
}))
|
||||
defer primary.Close()
|
||||
|
||||
pluginPortal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
pluginHits[r.URL.Path]++
|
||||
body, ok := paths[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
defer pluginPortal.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
proxy.HTTPClient = primary.Client()
|
||||
|
||||
h := NewMavenHandler(proxy, "http://localhost", primary.URL, pluginPortal.URL)
|
||||
srv := httptest.NewServer(h.Routes())
|
||||
defer srv.Close()
|
||||
|
||||
for reqPath, wantBody := range paths {
|
||||
resp, err := http.Get(srv.URL + reqPath)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s failed: %v", reqPath, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s: status = %d, want %d", reqPath, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if string(body) != wantBody {
|
||||
t.Fatalf("GET %s: body = %q, want %q", reqPath, body, wantBody)
|
||||
}
|
||||
|
||||
if primaryHits[reqPath] == 0 {
|
||||
t.Fatalf("GET %s did not hit primary upstream", reqPath)
|
||||
}
|
||||
if pluginHits[reqPath] == 0 {
|
||||
t.Fatalf("GET %s did not hit plugin portal fallback", reqPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMavenHandler_GradlePluginImplementation_FallbackToPluginPortal(t *testing.T) {
|
||||
proxy, _, _, fetcher := setupTestProxy(t)
|
||||
|
||||
primaryUpstream := "https://repo1.maven.org/maven2"
|
||||
pluginPortalUpstream := "https://plugins.gradle.org/m2"
|
||||
implPath := "/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar"
|
||||
primaryURL := primaryUpstream + implPath
|
||||
pluginPortalURL := pluginPortalUpstream + implPath
|
||||
|
||||
fetcher.fetchErrByURL = map[string]error{
|
||||
primaryURL: ErrUpstreamNotFound,
|
||||
}
|
||||
fetcher.artifact = &fetch.Artifact{
|
||||
Body: io.NopCloser(strings.NewReader("plugin impl jar")),
|
||||
ContentType: "application/java-archive",
|
||||
}
|
||||
|
||||
h := NewMavenHandler(proxy, "http://localhost", primaryUpstream, pluginPortalUpstream)
|
||||
srv := httptest.NewServer(h.Routes())
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + implPath)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if string(body) != "plugin impl jar" {
|
||||
t.Fatalf("body = %q, want %q", body, "plugin impl jar")
|
||||
}
|
||||
|
||||
if fetcher.fetchedURL != pluginPortalURL {
|
||||
t.Fatalf("implementation artifact should fallback to plugin portal; fetched URL = %q, want %q", fetcher.fetchedURL, pluginPortalURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMavenHandler_GradlePluginImplementation_NotFoundInBothUpstreams(t *testing.T) {
|
||||
proxy, _, _, fetcher := setupTestProxy(t)
|
||||
|
||||
primaryUpstream := "https://repo1.maven.org/maven2"
|
||||
pluginPortalUpstream := "https://plugins.gradle.org/m2"
|
||||
implPath := "/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar"
|
||||
primaryURL := primaryUpstream + implPath
|
||||
pluginPortalURL := pluginPortalUpstream + implPath
|
||||
|
||||
fetcher.fetchErrByURL = map[string]error{
|
||||
primaryURL: ErrUpstreamNotFound,
|
||||
pluginPortalURL: ErrUpstreamNotFound,
|
||||
}
|
||||
|
||||
h := NewMavenHandler(proxy, "http://localhost", primaryUpstream, pluginPortalUpstream)
|
||||
srv := httptest.NewServer(h.Routes())
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + implPath)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
|
||||
}
|
||||
|
||||
if fetcher.fetchedURL != pluginPortalURL {
|
||||
t.Fatalf("expected fallback attempt to plugin portal; fetched URL = %q, want %q", fetcher.fetchedURL, pluginPortalURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNuGetHandler_DownloadCacheMiss(t *testing.T) {
|
||||
proxy, _, _, fetcher := setupTestProxy(t)
|
||||
fetcher.artifact = &fetch.Artifact{
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/cooldown"
|
||||
)
|
||||
|
||||
func TestGemParseFilename(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ import (
|
|||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/proxy/internal/metrics"
|
||||
"github.com/git-pkgs/proxy/internal/storage"
|
||||
)
|
||||
|
||||
|
|
@ -96,28 +94,18 @@ func (h *GradleBuildCacheHandler) handleGetOrHead(w http.ResponseWriter, r *http
|
|||
w.Header().Set("Content-Type", gradleBuildCacheContentType)
|
||||
|
||||
if r.Method == http.MethodHead {
|
||||
existsStart := time.Now()
|
||||
exists, err := h.proxy.Storage.Exists(r.Context(), storagePath)
|
||||
metrics.RecordStorageOperation("read", time.Since(existsStart))
|
||||
if err != nil {
|
||||
metrics.RecordStorageError("read")
|
||||
h.proxy.Logger.Error("failed to check gradle build cache entry", "key", key, "error", err)
|
||||
http.Error(w, "failed to read cache entry", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
metrics.RecordCacheMiss("gradle")
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
metrics.RecordCacheHit("gradle")
|
||||
|
||||
sizeStart := time.Now()
|
||||
size, err := h.proxy.Storage.Size(r.Context(), storagePath)
|
||||
metrics.RecordStorageOperation("read", time.Since(sizeStart))
|
||||
if err != nil {
|
||||
metrics.RecordStorageError("read")
|
||||
} else if size >= 0 {
|
||||
if size, err := h.proxy.Storage.Size(r.Context(), storagePath); err == nil && size >= 0 {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
}
|
||||
|
||||
|
|
@ -125,22 +113,17 @@ func (h *GradleBuildCacheHandler) handleGetOrHead(w http.ResponseWriter, r *http
|
|||
return
|
||||
}
|
||||
|
||||
readStart := time.Now()
|
||||
reader, err := h.proxy.Storage.Open(r.Context(), storagePath)
|
||||
metrics.RecordStorageOperation("read", time.Since(readStart))
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
metrics.RecordCacheMiss("gradle")
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
metrics.RecordStorageError("read")
|
||||
h.proxy.Logger.Error("failed to open gradle build cache entry", "key", key, "error", err)
|
||||
http.Error(w, "failed to read cache entry", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
metrics.RecordCacheHit("gradle")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, reader)
|
||||
|
|
@ -155,9 +138,7 @@ func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Reque
|
|||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
||||
|
||||
storeStart := time.Now()
|
||||
_, hash, err := h.proxy.Storage.Store(r.Context(), storagePath, r.Body)
|
||||
metrics.RecordStorageOperation("write", time.Since(storeStart))
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
|
|
@ -165,7 +146,6 @@ func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Reque
|
|||
return
|
||||
}
|
||||
|
||||
metrics.RecordStorageError("write")
|
||||
h.proxy.Logger.Error("failed to store gradle build cache entry", "key", key, "error", err)
|
||||
http.Error(w, "failed to write cache entry", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@ import (
|
|||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/git-pkgs/proxy/internal/metrics"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
)
|
||||
|
||||
func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) {
|
||||
|
|
@ -230,56 +227,3 @@ func TestGradleBuildCacheHandler_PutTooLarge(t *testing.T) {
|
|||
t.Fatalf("PUT status = %d, want %d", resp.StatusCode, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGradleBuildCacheHandler_RecordsMetrics(t *testing.T) {
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
h := NewGradleBuildCacheHandler(proxy)
|
||||
srv := httptest.NewServer(h.Routes())
|
||||
defer srv.Close()
|
||||
|
||||
hitsBefore := testutil.ToFloat64(metrics.CacheHits.WithLabelValues("gradle"))
|
||||
missesBefore := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("gradle"))
|
||||
|
||||
key := "metrics-key"
|
||||
putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader("payload"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create PUT request: %v", err)
|
||||
}
|
||||
putResp, err := http.DefaultClient.Do(putReq)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT request failed: %v", err)
|
||||
}
|
||||
_ = putResp.Body.Close()
|
||||
|
||||
getResp, err := http.Get(srv.URL + "/" + key)
|
||||
if err != nil {
|
||||
t.Fatalf("GET request failed: %v", err)
|
||||
}
|
||||
_ = getResp.Body.Close()
|
||||
|
||||
headReq, err := http.NewRequest(http.MethodHead, srv.URL+"/"+key, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create HEAD request: %v", err)
|
||||
}
|
||||
headResp, err := http.DefaultClient.Do(headReq)
|
||||
if err != nil {
|
||||
t.Fatalf("HEAD request failed: %v", err)
|
||||
}
|
||||
_ = headResp.Body.Close()
|
||||
|
||||
missResp, err := http.Get(srv.URL + "/missing-key")
|
||||
if err != nil {
|
||||
t.Fatalf("GET miss request failed: %v", err)
|
||||
}
|
||||
_ = missResp.Body.Close()
|
||||
|
||||
hitsAfter := testutil.ToFloat64(metrics.CacheHits.WithLabelValues("gradle"))
|
||||
missesAfter := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("gradle"))
|
||||
|
||||
if diff := hitsAfter - hitsBefore; diff != 2 {
|
||||
t.Fatalf("cache hits delta = %.0f, want 2", diff)
|
||||
}
|
||||
if diff := missesAfter - missesBefore; diff != 1 {
|
||||
t.Fatalf("cache misses delta = %.0f, want 1", diff)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/database"
|
||||
"github.com/git-pkgs/proxy/internal/metrics"
|
||||
"github.com/git-pkgs/proxy/internal/storage"
|
||||
|
|
@ -680,14 +680,9 @@ func (p *Proxy) ProxyCached(w http.ResponseWriter, r *http.Request, upstreamURL,
|
|||
return
|
||||
}
|
||||
|
||||
p.writeMetadataCachedResponse(w, r, ecosystem, cacheKey, body, contentType)
|
||||
}
|
||||
|
||||
// writeMetadataCachedResponse writes a cached metadata response and handles
|
||||
// conditional request headers using metadata cache validators.
|
||||
func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, body []byte, contentType string) {
|
||||
cm := p.lookupCachedMeta(ecosystem, cacheKey)
|
||||
|
||||
// Honor client conditional request headers
|
||||
if cm.etag != "" {
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == cm.etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
|
|
|
|||
|
|
@ -97,11 +97,10 @@ func (s *mockStorage) Close() error { return nil }
|
|||
|
||||
// mockFetcher implements fetch.FetcherInterface for testing.
|
||||
type mockFetcher struct {
|
||||
artifact *fetch.Artifact
|
||||
fetchErr error
|
||||
fetchErrByURL map[string]error
|
||||
fetchCalled bool
|
||||
fetchedURL string
|
||||
artifact *fetch.Artifact
|
||||
fetchErr error
|
||||
fetchCalled bool
|
||||
fetchedURL string
|
||||
}
|
||||
|
||||
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
|
||||
|
|
@ -111,11 +110,6 @@ func (f *mockFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, e
|
|||
func (f *mockFetcher) FetchWithHeaders(_ context.Context, url string, _ http.Header) (*fetch.Artifact, error) {
|
||||
f.fetchCalled = true
|
||||
f.fetchedURL = url
|
||||
if f.fetchErrByURL != nil {
|
||||
if err, ok := f.fetchErrByURL[url]; ok {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if f.fetchErr != nil {
|
||||
return nil, f.fetchErr
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/cooldown"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
|
|
@ -9,33 +8,23 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
mavenCentralUpstream = "https://repo1.maven.org/maven2"
|
||||
gradlePluginPortalUpstream = "https://plugins.gradle.org/m2"
|
||||
minMavenParts = 4 // group path segments + artifact + version + filename
|
||||
mavenUpstream = "https://repo1.maven.org/maven2"
|
||||
minMavenParts = 4 // group path segments + artifact + version + filename
|
||||
)
|
||||
|
||||
// MavenHandler handles Maven repository protocol requests.
|
||||
type MavenHandler struct {
|
||||
proxy *Proxy
|
||||
upstreamURL string
|
||||
pluginPortalUpstreamURL string
|
||||
proxyURL string
|
||||
proxy *Proxy
|
||||
upstreamURL string
|
||||
proxyURL string
|
||||
}
|
||||
|
||||
// NewMavenHandler creates a new Maven repository handler.
|
||||
func NewMavenHandler(proxy *Proxy, proxyURL, upstreamURL, pluginPortalUpstreamURL string) *MavenHandler {
|
||||
if strings.TrimSpace(upstreamURL) == "" {
|
||||
upstreamURL = mavenCentralUpstream
|
||||
}
|
||||
if strings.TrimSpace(pluginPortalUpstreamURL) == "" {
|
||||
pluginPortalUpstreamURL = gradlePluginPortalUpstream
|
||||
}
|
||||
|
||||
func NewMavenHandler(proxy *Proxy, proxyURL string) *MavenHandler {
|
||||
return &MavenHandler{
|
||||
proxy: proxy,
|
||||
upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
|
||||
pluginPortalUpstreamURL: strings.TrimSuffix(pluginPortalUpstreamURL, "/"),
|
||||
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
||||
proxy: proxy,
|
||||
upstreamURL: mavenUpstream,
|
||||
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +51,8 @@ func (h *MavenHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|||
filename := path.Base(urlPath)
|
||||
|
||||
if h.isMetadataFile(filename) {
|
||||
h.handleMetadata(w, r, urlPath)
|
||||
cacheKey := strings.ReplaceAll(urlPath, "/", "_")
|
||||
h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "maven", cacheKey, "*/*")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -76,32 +66,6 @@ func (h *MavenHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|||
h.proxyUpstream(w, r)
|
||||
}
|
||||
|
||||
func (h *MavenHandler) handleMetadata(w http.ResponseWriter, r *http.Request, urlPath string) {
|
||||
cacheKey := strings.ReplaceAll(urlPath, "/", "_")
|
||||
upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, urlPath)
|
||||
|
||||
body, contentType, err := h.proxy.FetchOrCacheMetadata(r.Context(), "maven", cacheKey, upstreamURL, "*/*")
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamNotFound) {
|
||||
pluginPortalURL := fmt.Sprintf("%s/%s", h.pluginPortalUpstreamURL, urlPath)
|
||||
h.proxy.Logger.Info("maven metadata unavailable in primary upstream, trying Gradle Plugin Portal",
|
||||
"path", urlPath)
|
||||
body, contentType, err = h.proxy.FetchOrCacheMetadata(r.Context(), "maven", cacheKey, pluginPortalURL, "*/*")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamNotFound) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
h.proxy.Logger.Error("metadata fetch failed", "error", err)
|
||||
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
h.proxy.writeMetadataCachedResponse(w, r, "maven", cacheKey, body, contentType)
|
||||
}
|
||||
|
||||
// handleDownload serves an artifact file, fetching and caching from upstream if needed.
|
||||
func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, urlPath string) {
|
||||
// Parse Maven path: group/artifact/version/filename
|
||||
|
|
@ -122,18 +86,6 @@ func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, ur
|
|||
|
||||
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "maven", name, version, filename, upstreamURL)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamNotFound) {
|
||||
pluginPortalURL := fmt.Sprintf("%s/%s", h.pluginPortalUpstreamURL, urlPath)
|
||||
h.proxy.Logger.Info("maven artifact not found in primary upstream, trying Gradle Plugin Portal",
|
||||
"group", group, "artifact", artifact, "version", version, "filename", filename)
|
||||
result, err = h.proxy.GetOrFetchArtifactFromURL(r.Context(), "maven", name, version, filename, pluginPortalURL)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamNotFound) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
||||
http.Error(w, "failed to fetch artifact", http.StatusBadGateway)
|
||||
return
|
||||
|
|
@ -163,7 +115,7 @@ func (h *MavenHandler) parsePath(urlPath string) (group, artifact, version, file
|
|||
// isArtifactFile returns true if the filename looks like a Maven artifact.
|
||||
func (h *MavenHandler) isArtifactFile(filename string) bool {
|
||||
// Common artifact extensions
|
||||
extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib", ".module"}
|
||||
extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib"}
|
||||
for _, ext := range extensions {
|
||||
if strings.HasSuffix(filename, ext) {
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ func TestMavenIsArtifactFile(t *testing.T) {
|
|||
}{
|
||||
{"guava-32.1.3-jre.jar", true},
|
||||
{"guava-32.1.3-jre.pom", true},
|
||||
{"guava-32.1.3-jre.module", true},
|
||||
{"app-1.0.war", true},
|
||||
{"lib-1.0.aar", true},
|
||||
{"maven-metadata.xml", false},
|
||||
|
|
@ -66,63 +65,3 @@ func TestMavenIsArtifactFile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMavenIsMetadataFile(t *testing.T) {
|
||||
h := &MavenHandler{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "pom is artifact, not metadata",
|
||||
filename: "com.diffplug.spotless.gradle.plugin-8.4.0.pom",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "pom checksum is metadata",
|
||||
filename: "com.diffplug.spotless.gradle.plugin-8.4.0.pom.sha1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "metadata file",
|
||||
filename: "maven-metadata.xml",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "metadata checksum",
|
||||
filename: "maven-metadata.xml.sha256",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "jar checksum is metadata",
|
||||
filename: "guava-32.1.3-jre.jar.sha1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "asc signature is metadata",
|
||||
filename: "guava-32.1.3-jre.jar.asc",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "regular jar is not metadata",
|
||||
filename: "guava-32.1.3-jre.jar",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "pom checksum is metadata",
|
||||
filename: "guava-32.1.3-jre.pom.sha1",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := h.isMetadataFile(tt.filename)
|
||||
if got != tt.want {
|
||||
t.Errorf("isMetadataFile(%q) = %v, want %v", tt.filename, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/cooldown"
|
||||
)
|
||||
|
||||
const testVersion100 = "1.0.0"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/cooldown"
|
||||
)
|
||||
|
||||
func nugetTestProxy() *Proxy {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/cooldown"
|
||||
)
|
||||
|
||||
func TestPubRewriteMetadata(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/cooldown"
|
||||
"github.com/git-pkgs/registries/fetch"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -128,14 +128,6 @@ var (
|
|||
},
|
||||
[]string{"ecosystem"},
|
||||
)
|
||||
|
||||
HealthProbeFailures = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "proxy_health_probe_failures_total",
|
||||
Help: "Total number of storage health probe failures, by step (write|size|read|verify|delete).",
|
||||
},
|
||||
[]string{"step"},
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
|
@ -155,7 +147,6 @@ func init() {
|
|||
StorageErrors,
|
||||
ActiveRequests,
|
||||
IntegrityFailures,
|
||||
HealthProbeFailures,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -201,12 +192,6 @@ func RecordIntegrityFailure(ecosystem string) {
|
|||
IntegrityFailures.WithLabelValues(ecosystem).Inc()
|
||||
}
|
||||
|
||||
// RecordHealthProbeFailure increments the health probe failure counter.
|
||||
// step is one of: "write", "size", "read", "verify", "delete".
|
||||
func RecordHealthProbeFailure(step string) {
|
||||
HealthProbeFailures.WithLabelValues(step).Inc()
|
||||
}
|
||||
|
||||
// RecordStorageError increments storage error counter.
|
||||
func RecordStorageError(operation string) {
|
||||
StorageErrors.WithLabelValues(operation).Inc()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,6 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
|
@ -51,7 +50,7 @@ import (
|
|||
|
||||
swaggerdoc "github.com/git-pkgs/proxy/docs/swagger"
|
||||
"github.com/git-pkgs/proxy/internal/config"
|
||||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/database"
|
||||
"github.com/git-pkgs/proxy/internal/enrichment"
|
||||
"github.com/git-pkgs/proxy/internal/handler"
|
||||
|
|
@ -81,8 +80,7 @@ type Server struct {
|
|||
logger *slog.Logger
|
||||
http *http.Server
|
||||
templates *Templates
|
||||
cancel context.CancelFunc
|
||||
healthCache *healthCache
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a new Server with the given configuration.
|
||||
|
|
@ -128,20 +126,12 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
|
|||
return nil, fmt.Errorf("verifying storage connectivity: %w", err)
|
||||
}
|
||||
|
||||
hc, err := newHealthCache(store, cfg.Health.StorageProbeInterval, logger)
|
||||
if err != nil {
|
||||
_ = store.Close()
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("initializing health cache: %w", err)
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
storage: store,
|
||||
logger: logger,
|
||||
templates: &Templates{},
|
||||
healthCache: hc,
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
storage: store,
|
||||
logger: logger,
|
||||
templates: &Templates{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -193,12 +183,7 @@ func (s *Server) Start() error {
|
|||
hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL)
|
||||
pubHandler := handler.NewPubHandler(proxy, s.cfg.BaseURL)
|
||||
pypiHandler := handler.NewPyPIHandler(proxy, s.cfg.BaseURL)
|
||||
mavenHandler := handler.NewMavenHandler(
|
||||
proxy,
|
||||
s.cfg.BaseURL,
|
||||
s.cfg.Upstream.Maven,
|
||||
s.cfg.Upstream.GradlePluginPortal,
|
||||
)
|
||||
mavenHandler := handler.NewMavenHandler(proxy, s.cfg.BaseURL)
|
||||
gradleHandler := handler.NewGradleBuildCacheHandler(proxy)
|
||||
nugetHandler := handler.NewNuGetHandler(proxy, s.cfg.BaseURL)
|
||||
composerHandler := handler.NewComposerHandler(proxy, s.cfg.BaseURL)
|
||||
|
|
@ -817,49 +802,23 @@ func (s *Server) showComparePage(w http.ResponseWriter, ecosystem, name, version
|
|||
}
|
||||
}
|
||||
|
||||
// handleHealth responds with a structured JSON health report.
|
||||
//
|
||||
// handleHealth responds with a simple health check.
|
||||
// @Summary Health check
|
||||
// @Tags meta
|
||||
// @Produce json
|
||||
// @Success 200 {object} HealthResponse
|
||||
// @Failure 503 {object} HealthResponse
|
||||
// @Produce plain
|
||||
// @Success 200 {string} string
|
||||
// @Failure 503 {string} string
|
||||
// @Router /health [get]
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
resp := HealthResponse{Status: "ok", Checks: map[string]HealthCheck{}}
|
||||
|
||||
// Database check (short-circuit; do not waste a storage probe call when DB is down).
|
||||
// On DB failure the storage entry reports "skipped" rather than being omitted so
|
||||
// the response always carries the same key set for monitors that expect it.
|
||||
// Check database connectivity
|
||||
if _, err := s.db.SchemaVersion(); err != nil {
|
||||
resp.Status = "error"
|
||||
resp.Checks["database"] = HealthCheck{Status: "error", Error: err.Error()}
|
||||
resp.Checks["storage"] = HealthCheck{Status: "skipped"}
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
_, _ = fmt.Fprint(w, "database error")
|
||||
return
|
||||
}
|
||||
resp.Checks["database"] = HealthCheck{Status: "ok"}
|
||||
|
||||
// Storage probe (via cache).
|
||||
if err := s.healthCache.Check(); err != nil {
|
||||
resp.Status = "error"
|
||||
sc := HealthCheck{Status: "error", Error: err.Error()}
|
||||
var pe *probeError
|
||||
if errors.As(err, &pe) {
|
||||
sc.Step = pe.step
|
||||
}
|
||||
resp.Checks["storage"] = sc
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
resp.Checks["storage"] = HealthCheck{Status: "ok"}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
}
|
||||
|
||||
// StatsResponse contains cache statistics.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue