Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce8026d46e | |||
| 365d93d637 | |||
| bc68eb75fc | |||
| 4c321a089d | |||
| 9161558334 | |||
| 492607a164 | |||
| 1780b3d77c | |||
| 1837f6ef8c | |||
| 80368986bb |
+26
-12
@@ -4,31 +4,45 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: docker-api
|
- name: docker-api
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||||
settings:
|
settings:
|
||||||
registry: git.unkin.net
|
registry: artifactapi.k8s.syd1.au.unkin.net
|
||||||
repo: git.unkin.net/unkin/artifactapi
|
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/artifactapi
|
||||||
build_args:
|
build_args:
|
||||||
VERSION: ${CI_COMMIT_TAG}
|
VERSION: ${CI_COMMIT_TAG}
|
||||||
username: droneci
|
|
||||||
password:
|
|
||||||
from_secret: DRONECI_PASSWORD
|
|
||||||
tags:
|
tags:
|
||||||
- ${CI_COMMIT_TAG}
|
- ${CI_COMMIT_TAG}
|
||||||
- latest
|
- latest
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 1Gi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 4Gi
|
||||||
|
cpu: 2
|
||||||
|
|
||||||
- name: docker-web
|
- name: docker-web
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||||
settings:
|
settings:
|
||||||
registry: git.unkin.net
|
registry: artifactapi.k8s.syd1.au.unkin.net
|
||||||
repo: git.unkin.net/unkin/artifactapi-ui
|
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/artifactapi-ui
|
||||||
dockerfile: ui/Dockerfile.ui
|
dockerfile: ui/Dockerfile.ui
|
||||||
context: ui
|
context: ui
|
||||||
build_args:
|
build_args:
|
||||||
BASE_PATH: /ui
|
BASE_PATH: /ui
|
||||||
username: droneci
|
|
||||||
password:
|
|
||||||
from_secret: DRONECI_PASSWORD
|
|
||||||
tags:
|
tags:
|
||||||
- ${CI_COMMIT_TAG}
|
- ${CI_COMMIT_TAG}
|
||||||
- latest
|
- latest
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 1Gi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 4Gi
|
||||||
|
cpu: 2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ArtifactAPI
|
# ArtifactAPI
|
||||||
|
|
||||||
Caching proxy for package repositories. Single Go binary, 10 package types, content-addressable storage, managed by Terraform.
|
Caching proxy for package repositories. Single Go binary, 12 package types, content-addressable storage, managed by Terraform.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -32,6 +32,8 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173`
|
|||||||
| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` |
|
| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` |
|
||||||
| `terraform` | `*/versions` | `*/download/*/*` |
|
| `terraform` | `*/versions` | `*/download/*/*` |
|
||||||
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
|
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
|
||||||
|
| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` |
|
||||||
|
| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` |
|
||||||
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
|
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
|
||||||
|
|
||||||
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
||||||
@@ -278,6 +280,20 @@ content-addressable store (deduplicated by digest, reaped by GC once
|
|||||||
unreferenced); tags are mutable references and re-pushing a tag moves it. Blob
|
unreferenced); tags are mutable references and re-pushing a tag moves it. Blob
|
||||||
uploads support both the monolithic and chunked (`POST`/`PATCH`/`PUT`) flows.
|
uploads support both the monolithic and chunked (`POST`/`PATCH`/`PUT`) flows.
|
||||||
|
|
||||||
|
### Local cargo registry
|
||||||
|
|
||||||
|
A local `cargo` repo is a private crate registry: it serves the sparse index and
|
||||||
|
the Web API publish, yank and unyank endpoints, so `cargo publish` works against
|
||||||
|
it directly.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[registries.<repo>]
|
||||||
|
index = "sparse+https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/<repo>/"
|
||||||
|
```
|
||||||
|
|
||||||
|
cargo requires a token for publish/yank (`CARGO_REGISTRIES_<REPO>_TOKEN`); like
|
||||||
|
other local repos, artifactapi does not check it.
|
||||||
|
|
||||||
## Access Control
|
## Access Control
|
||||||
|
|
||||||
| Field | Default | Behaviour |
|
| Field | Default | Behaviour |
|
||||||
@@ -370,6 +386,8 @@ additive and idempotent for the same reason; a test enforces it.
|
|||||||
| `GITHUB_APP_INSTALLATION_ID` | | GitHub App installation id |
|
| `GITHUB_APP_INSTALLATION_ID` | | GitHub App installation id |
|
||||||
| `GITHUB_APP_PRIVATE_KEY` | | GitHub App private key, inline PEM |
|
| `GITHUB_APP_PRIVATE_KEY` | | GitHub App private key, inline PEM |
|
||||||
| `GITHUB_APP_PRIVATE_KEY_PATH` | | GitHub App private key, file path (alternative to inline PEM) |
|
| `GITHUB_APP_PRIVATE_KEY_PATH` | | GitHub App private key, file path (alternative to inline PEM) |
|
||||||
|
| `ARTIFACTAPI_OIDC_ISSUER` | | OIDC issuer URL (e.g. Vault `https://vault.service.consul:8200/v1/identity/oidc`). When set, `POST /api/v2/remotes`, `POST /api/v2/virtuals` and `PUT`/`DELETE` on `/api/v2/{remotes,virtuals}/{name}` require `Authorization: Bearer <jwt>` verified against the issuer's JWKS. Uploads, object/file deletes, probe, reads and `/v2` stay open. Unset = no auth |
|
||||||
|
| `ARTIFACTAPI_OIDC_AUDIENCE` | | Comma-separated allowed `aud` values (e.g. the Vault OIDC role's client_id). Required when `ARTIFACTAPI_OIDC_ISSUER` is set |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ already-running stack.
|
|||||||
|
|
||||||
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
||||||
- **Caching** — one immutable artifact per remote package type (generic, docker,
|
- **Caching** — one immutable artifact per remote package type (generic, docker,
|
||||||
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy) proxied through the
|
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, nuget) proxied through the
|
||||||
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||||
verified against the origin fixture.
|
verified against the origin fixture.
|
||||||
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
||||||
index), rpm (real package + **automatic repodata** generation).
|
index), rpm (real package + **automatic repodata** generation), cargo (publish,
|
||||||
|
sparse index, download, yank).
|
||||||
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
|
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
|
||||||
across two members.
|
across two members.
|
||||||
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
|
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ func TestCachingPerProvider(t *testing.T) {
|
|||||||
{"alpine", "alpine/x86_64/testpkg-1.0-r0.apk", "alpine/x86_64/testpkg-1.0-r0.apk"},
|
{"alpine", "alpine/x86_64/testpkg-1.0-r0.apk", "alpine/x86_64/testpkg-1.0-r0.apk"},
|
||||||
{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"},
|
{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"},
|
||||||
{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"},
|
{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"},
|
||||||
|
{"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"},
|
||||||
|
{"nuget", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg"},
|
||||||
{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"},
|
{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"},
|
||||||
{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"},
|
{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//go:build dockere2e
|
||||||
|
|
||||||
|
package e2edocker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLocalCargoRegistry publishes through the cargo Web API, then reads the
|
||||||
|
// sparse index, downloads the crate and yanks it.
|
||||||
|
func TestLocalCargoRegistry(t *testing.T) {
|
||||||
|
createRepo(t, `{"name":"local-cargo","package_type":"cargo","repo_type":"local"}`)
|
||||||
|
defer deleteRepo(t, "local-cargo")
|
||||||
|
base := api("/api/v1/local/local-cargo")
|
||||||
|
|
||||||
|
meta := `{"name":"e2e-crate","vers":"0.1.0","deps":[],"features":{}}`
|
||||||
|
crate := []byte("e2e crate bytes")
|
||||||
|
publish := func(meta string) *http.Response {
|
||||||
|
var body []byte
|
||||||
|
body = binary.LittleEndian.AppendUint32(body, uint32(len(meta)))
|
||||||
|
body = append(body, meta...)
|
||||||
|
body = binary.LittleEndian.AppendUint32(body, uint32(len(crate)))
|
||||||
|
body = append(body, crate...)
|
||||||
|
resp, _ := doRequest(t, http.MethodPut, base+"/api/v1/crates/new", body, "application/octet-stream")
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp := publish(meta); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("publish: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp := publish(meta); resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("republish: status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp := publish(`{"name":"e2e-crate","vers":"0.1.0+other","deps":[],"features":{}}`); resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("build-metadata republish: status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp := publish(`{"name":"E2E_crate","vers":"0.2.0","deps":[],"features":{}}`); resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("name collision publish: status %d, want 400", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, b := doRequest(t, http.MethodGet, base+"/config.json", nil, "")
|
||||||
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(b), `"api":"`) {
|
||||||
|
t.Fatalf("config.json: status %d: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, b = doRequest(t, http.MethodGet, base+"/e2/e-/e2e-crate", nil, "")
|
||||||
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(b), `"cksum":"`) || !strings.Contains(string(b), `"yanked":false`) {
|
||||||
|
t.Fatalf("index: status %d: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, b = doRequest(t, http.MethodGet, base+"/crates/e2e-crate/e2e-crate-0.1.0.crate", nil, "")
|
||||||
|
if resp.StatusCode != http.StatusOK || !bytes.Equal(b, crate) {
|
||||||
|
t.Fatalf("download: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, b := doRequest(t, http.MethodDelete, base+"/api/v1/crates/e2e-crate/0.1.0/yank", nil, ""); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("yank: status %d: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
if _, b := doRequest(t, http.MethodGet, base+"/e2/e-/e2e-crate", nil, ""); !strings.Contains(string(b), `"yanked":true`) {
|
||||||
|
t.Fatalf("index after yank: %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -3,9 +3,17 @@
|
|||||||
package e2edocker
|
package e2edocker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestVirtualPyPIMerge uploads different packages to two pypi locals and
|
// TestVirtualPyPIMerge uploads different packages to two pypi locals and
|
||||||
@@ -52,3 +60,79 @@ func TestVirtualHelmMerge(t *testing.T) {
|
|||||||
t.Fatalf("merged helm index missing a member chart (want alpha and beta): %s", s)
|
t.Fatalf("merged helm index missing a member chart (want alpha and beta): %s", s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVirtualRPMMerge merges a remote rpm repo and a local rpm repo carrying the
|
||||||
|
// same package: the first member wins the duplicate, its package download
|
||||||
|
// routes through the virtual, and a real dnf can consume the merged repo.
|
||||||
|
func TestVirtualRPMMerge(t *testing.T) {
|
||||||
|
createRepo(t, `{"name":"vrpm-remote","package_type":"rpm","repo_type":"remote","base_url":"`+mockUpstream()+`/rpm-mirror","stale_on_error":true}`)
|
||||||
|
createRepo(t, `{"name":"vrpm-local","package_type":"rpm","repo_type":"local"}`)
|
||||||
|
defer deleteRepo(t, "vrpm-remote")
|
||||||
|
defer deleteRepo(t, "vrpm-local")
|
||||||
|
|
||||||
|
pkg := fixtureBytes(t, "rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm")
|
||||||
|
uploadFile(t, "vrpm-local", "e2e-testpkg-1.0-1.noarch.rpm", pkg, "application/x-rpm")
|
||||||
|
|
||||||
|
createVirtual(t, `{"name":"vrpm","package_type":"rpm","members":["vrpm-remote","vrpm-local"]}`)
|
||||||
|
defer deleteVirtual(t, "vrpm")
|
||||||
|
|
||||||
|
resp, body := getEventually(t, api("/api/v1/virtual/vrpm/repodata/repomd.xml"), 15*time.Second)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("virtual repomd.xml: status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var md struct {
|
||||||
|
Data []struct {
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Location struct {
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
} `xml:"location"`
|
||||||
|
} `xml:"data"`
|
||||||
|
}
|
||||||
|
if err := xml.Unmarshal(body, &md); err != nil {
|
||||||
|
t.Fatalf("parse repomd.xml: %v\n%s", err, body)
|
||||||
|
}
|
||||||
|
var primary []byte
|
||||||
|
for _, d := range md.Data {
|
||||||
|
if d.Type == "primary" {
|
||||||
|
resp, gz := doRequest(t, http.MethodGet, api("/api/v1/virtual/vrpm/"+d.Location.Href), nil, "")
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("primary: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
r, err := gzip.NewReader(bytes.NewReader(gz))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
primary, _ = io.ReadAll(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
href := "vrpm-remote/Packages/e2e-testpkg-1.0-1.noarch.rpm"
|
||||||
|
if strings.Count(string(primary), "<name>e2e-testpkg</name>") != 1 || !strings.Contains(string(primary), `href="`+href+`"`) {
|
||||||
|
t.Fatalf("merged primary should hold one e2e-testpkg owned by the first member:\n%s", primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, got := doRequest(t, http.MethodGet, api("/api/v1/virtual/vrpm/"+href), nil, "")
|
||||||
|
if resp.StatusCode != http.StatusOK || !bytes.Equal(got, pkg) {
|
||||||
|
t.Fatalf("package via virtual: status %d, %d bytes (want %d)", resp.StatusCode, len(got), len(pkg))
|
||||||
|
}
|
||||||
|
|
||||||
|
network := os.Getenv("COMPOSE_NETWORK")
|
||||||
|
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||||
|
if network == "" || internal == "" {
|
||||||
|
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real dnf")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
t.Skip("docker not available on the test host")
|
||||||
|
}
|
||||||
|
repoConf := fmt.Sprintf("[vrpm]\nname=vrpm\nbaseurl=%s/api/v1/virtual/vrpm/\nenabled=1\ngpgcheck=0\nmetadata_expire=0\n", strings.TrimRight(internal, "/"))
|
||||||
|
script := "set -euo pipefail; " +
|
||||||
|
"printf '%s' \"$REPO\" > /etc/yum.repos.d/vrpm.repo; " +
|
||||||
|
"dnf -y --disablerepo='*' --enablerepo=vrpm makecache; " +
|
||||||
|
"dnf -y --disablerepo='*' --enablerepo=vrpm list --available; " +
|
||||||
|
"dnf -y --disablerepo='*' --enablerepo=vrpm install e2e-testpkg; " +
|
||||||
|
"rpm -q e2e-testpkg"
|
||||||
|
out, err := exec.Command("docker", "run", "--rm", "--network", network, "-e", "REPO="+repoConf,
|
||||||
|
"rockylinux:9", "bash", "-c", script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("real dnf against rpm virtual failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ require (
|
|||||||
github.com/cavaliergopher/rpm v1.3.0
|
github.com/cavaliergopher/rpm v1.3.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/klauspost/compress v1.19.2
|
github.com/klauspost/compress v1.19.2
|
||||||
@@ -18,6 +20,7 @@ require (
|
|||||||
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
|
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
|
||||||
github.com/ulikunitz/xz v0.5.16
|
github.com/ulikunitz/xz v0.5.16
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
|
golang.org/x/sync v0.22.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -101,7 +104,7 @@ require (
|
|||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/net v0.56.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.2 // indirect
|
gopkg.in/ini.v1 v1.67.2 // indirect
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
|||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
@@ -67,6 +69,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO
|
|||||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -225,6 +229,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
|
|||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider/cargo"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file implements the cargo registry Web API (publish, yank, unyank) for
|
||||||
|
// local cargo repositories. config.json points api at /api/v1/local/<repo>, so
|
||||||
|
// cargo appends its own /api/v1/crates/... below that.
|
||||||
|
|
||||||
|
const maxCratePublish = 64 << 20
|
||||||
|
|
||||||
|
func (h *ProxyHandler) localCargoRemote(r *http.Request) (*models.Remote, bool) {
|
||||||
|
remote, err := h.db.GetRemote(r.Context(), chi.URLParam(r, "localName"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return remote, remote.RepoType == models.RepoTypeLocal && remote.PackageType == models.PackageCargo
|
||||||
|
}
|
||||||
|
|
||||||
|
func cargoError(w http.ResponseWriter, status int, format string, args ...any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"errors": []map[string]string{{"detail": fmt.Sprintf(format, args...)}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func cargoOK(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProxyHandler) cargoPublish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
remote, ok := h.localCargoRemote(r)
|
||||||
|
if !ok {
|
||||||
|
cargoError(w, http.StatusNotFound, "no local cargo registry %q", chi.URLParam(r, "localName"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxCratePublish+1))
|
||||||
|
if err != nil {
|
||||||
|
cargoError(w, http.StatusBadRequest, "read body: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body) > maxCratePublish {
|
||||||
|
cargoError(w, http.StatusRequestEntityTooLarge, "publish body exceeds %d bytes", maxCratePublish)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, crate, err := cargo.ParsePublish(body)
|
||||||
|
if err != nil {
|
||||||
|
cargoError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line, err := cargo.NewIndexLine(meta, crate)
|
||||||
|
if err != nil {
|
||||||
|
cargoError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lineJSON, err := json.Marshal(line)
|
||||||
|
if err != nil {
|
||||||
|
cargoError(w, http.StatusInternalServerError, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := cargo.CratePath(meta.Name, meta.Vers)
|
||||||
|
result, err := h.cas.Store(ctx, bytes.NewReader(crate), "application/gzip")
|
||||||
|
if err != nil {
|
||||||
|
cargoError(w, http.StatusInternalServerError, "store failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UpsertBlob(ctx, result.ContentHash, result.S3Key, result.SizeBytes, "application/gzip"); err != nil {
|
||||||
|
cargoError(w, http.StatusInternalServerError, "record blob: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.CreateLocalFile(ctx, remote.Name, path, result.ContentHash); err != nil {
|
||||||
|
if errors.Is(err, database.ErrAlreadyExists) {
|
||||||
|
cargoError(w, http.StatusConflict, "crate version `%s@%s` already exists", meta.Name, meta.Vers)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cargoError(w, http.StatusInternalServerError, "record file: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := &provider.CargoIndexEntry{RepoName: remote.Name, FilePath: path, Name: meta.Name, Version: meta.Vers, Line: string(lineJSON)}
|
||||||
|
if err := h.db.InsertCargoIndexEntry(ctx, entry); err != nil {
|
||||||
|
// Drop the file so a retry is not rejected as a duplicate.
|
||||||
|
_ = h.db.DeleteLocalFile(ctx, remote.Name, path)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, database.ErrCargoNameTaken):
|
||||||
|
cargoError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
case errors.Is(err, database.ErrAlreadyExists):
|
||||||
|
cargoError(w, http.StatusConflict, "crate version `%s@%s` already exists", meta.Name, meta.Vers)
|
||||||
|
default:
|
||||||
|
cargoError(w, http.StatusInternalServerError, "record index: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("cargo: published", "repo", remote.Name, "crate", meta.Name, "version", meta.Vers)
|
||||||
|
cargoOK(w, map[string]any{"warnings": map[string][]string{"invalid_categories": {}, "invalid_badges": {}, "other": {}}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProxyHandler) cargoYank(yanked bool) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
remote, ok := h.localCargoRemote(r)
|
||||||
|
if !ok {
|
||||||
|
cargoError(w, http.StatusNotFound, "no local cargo registry %q", chi.URLParam(r, "localName"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, version := chi.URLParam(r, "crate"), chi.URLParam(r, "version")
|
||||||
|
found, err := h.db.SetCargoYanked(r.Context(), remote.Name, name, version, yanked)
|
||||||
|
if err != nil {
|
||||||
|
cargoError(w, http.StatusInternalServerError, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cargoError(w, http.StatusNotFound, "crate version `%s@%s` not found", name, version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("cargo: yank updated", "repo", remote.Name, "crate", name, "version", version, "yanked", yanked)
|
||||||
|
cargoOK(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -41,6 +42,9 @@ func (h *ProxyHandler) Routes() chi.Router {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/remote/{remoteName}/*", h.handleProxy)
|
r.Get("/remote/{remoteName}/*", h.handleProxy)
|
||||||
r.Get("/local/{localName}/*", h.handleLocal)
|
r.Get("/local/{localName}/*", h.handleLocal)
|
||||||
|
r.Put("/local/{localName}/api/v1/crates/new", h.cargoPublish)
|
||||||
|
r.Delete("/local/{localName}/api/v1/crates/{crate}/{version}/yank", h.cargoYank(true))
|
||||||
|
r.Put("/local/{localName}/api/v1/crates/{crate}/{version}/unyank", h.cargoYank(false))
|
||||||
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
|
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -105,13 +109,24 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer result.Reader.Close()
|
defer result.Reader.Close()
|
||||||
|
|
||||||
|
var body io.Reader = result.Reader
|
||||||
|
if sr, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable {
|
||||||
|
raw, err := io.ReadAll(result.Reader)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw = sr.RewriteServed(raw, *remote, fmt.Sprintf("%s://%s", scheme(r), r.Host))
|
||||||
|
body, result.Size = bytes.NewReader(raw), int64(len(raw))
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", result.ContentType)
|
w.Header().Set("Content-Type", result.ContentType)
|
||||||
w.Header().Set("X-Artifact-Source", result.Source)
|
w.Header().Set("X-Artifact-Source", result.Source)
|
||||||
if result.Size > 0 {
|
if result.Size > 0 {
|
||||||
w.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size))
|
w.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size))
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
io.Copy(w, result.Reader)
|
io.Copy(w, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -162,7 +177,21 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
proxyBaseURL := fmt.Sprintf("%s://%s", scheme(r), r.Host)
|
proxyBaseURL := fmt.Sprintf("%s://%s", scheme(r), r.Host)
|
||||||
|
|
||||||
|
loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), *virt, path, r.URL.RawQuery, proxyBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
http.Redirect(w, r, loc, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
body, contentType, err := h.virtualEngine.Fetch(r.Context(), *virt, path, proxyBaseURL)
|
body, contentType, err := h.virtualEngine.Fetch(r.Context(), *virt, path, proxyBaseURL)
|
||||||
|
if errors.Is(err, virtual.ErrNotFound) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("virtual fetch failed", "virtual", virtualName, "path", path, "error", err)
|
slog.Error("virtual fetch failed", "virtual", virtualName, "path", path, "error", err)
|
||||||
http.Error(w, "bad gateway", http.StatusBadGateway)
|
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BearerOIDC verifies `Authorization: Bearer <jwt>` against an OIDC issuer's
|
||||||
|
// JWKS. Discovery is lazy so startup never depends on the issuer being
|
||||||
|
// reachable; a failed discovery is not retried until the backoff elapses.
|
||||||
|
type BearerOIDC struct {
|
||||||
|
issuer string
|
||||||
|
audiences []string
|
||||||
|
timeout time.Duration
|
||||||
|
backoff time.Duration
|
||||||
|
discover func(context.Context, string) (*oidc.Provider, error)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
verifier *oidc.IDTokenVerifier
|
||||||
|
inflight chan struct{}
|
||||||
|
failedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var errIssuerUnavailable = errors.New("oidc issuer unavailable")
|
||||||
|
|
||||||
|
// NewBearerOIDC returns nil when issuer is empty, meaning auth is disabled.
|
||||||
|
func NewBearerOIDC(issuer string, audiences []string) *BearerOIDC {
|
||||||
|
if issuer == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &BearerOIDC{issuer: issuer, audiences: audiences, timeout: 10 * time.Second, backoff: 15 * time.Second, discover: oidc.NewProvider}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVerifier runs at most one discovery at a time without holding the lock
|
||||||
|
// across network I/O; concurrent callers wait for it, and only callers within
|
||||||
|
// the post-failure backoff fail fast.
|
||||||
|
func (b *BearerOIDC) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if v := b.verifier; v != nil {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if done := b.inflight; done != nil {
|
||||||
|
b.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.verifier == nil {
|
||||||
|
return nil, errIssuerUnavailable
|
||||||
|
}
|
||||||
|
return b.verifier, nil
|
||||||
|
}
|
||||||
|
if time.Since(b.failedAt) < b.backoff {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return nil, errIssuerUnavailable
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
b.inflight = done
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
v, err := b.discoverOnce(done)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("oidc discovery failed", "issuer", b.issuer, "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverOnce releases inflight and waiters on every exit, a panic included,
|
||||||
|
// recording failedAt unless discovery succeeded.
|
||||||
|
func (b *BearerOIDC) discoverOnce(done chan struct{}) (v *oidc.IDTokenVerifier, err error) {
|
||||||
|
defer func() {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.inflight = nil
|
||||||
|
if v == nil {
|
||||||
|
b.failedAt = time.Now()
|
||||||
|
}
|
||||||
|
b.verifier = v
|
||||||
|
b.mu.Unlock()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
// The provider keeps this context and client for JWKS refreshes, so the
|
||||||
|
// deadline lives on the client rather than the context.
|
||||||
|
client := &http.Client{Timeout: b.timeout}
|
||||||
|
p, err := b.discover(oidc.ClientContext(context.Background(), client), b.issuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Verifier(&oidc.Config{SkipClientIDCheck: true}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require gates every request through next on a valid token. A nil receiver
|
||||||
|
// passes everything through.
|
||||||
|
func (b *BearerOIDC) Require(next http.Handler) http.Handler {
|
||||||
|
if b == nil {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scheme, raw, _ := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if !strings.EqualFold(scheme, "Bearer") || raw == "" {
|
||||||
|
unauthorized(w, "missing bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := b.getVerifier(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"token issuer unavailable"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok, err := v.Verify(r.Context(), raw)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("bearer token rejected", "error", err)
|
||||||
|
unauthorized(w, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) {
|
||||||
|
slog.Info("bearer token rejected", "error", "audience not allowed", "aud", tok.Audience)
|
||||||
|
unauthorized(w, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("bearer token accepted", "sub", tok.Subject, "method", r.Method, "path", r.URL.Path)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireWrites gates only mutating methods.
|
||||||
|
func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler {
|
||||||
|
gated := b.Require(next)
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
default:
|
||||||
|
gated.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func unauthorized(w http.ResponseWriter, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/go-jose/go-jose/v4"
|
||||||
|
"github.com/go-jose/go-jose/v4/jwt"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serve(h http.Handler, method, token string) int {
|
||||||
|
if token == "" {
|
||||||
|
return serveAuth(h, method, "")
|
||||||
|
}
|
||||||
|
return serveAuth(h, method, "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveAuth(h http.Handler, method, authz string) int {
|
||||||
|
r := httptest.NewRequest(method, "/x", nil)
|
||||||
|
if authz != "" {
|
||||||
|
r.Header.Set("Authorization", authz)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func claims(iss string) jwt.Claims {
|
||||||
|
now := time.Now()
|
||||||
|
return jwt.Claims{
|
||||||
|
Issuer: iss,
|
||||||
|
Subject: "ci",
|
||||||
|
Audience: jwt.Audience{"artifactapi"},
|
||||||
|
IssuedAt: jwt.NewNumericDate(now.Add(-time.Minute)),
|
||||||
|
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hs256Token(t *testing.T, c jwt.Claims) string {
|
||||||
|
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: []byte("0123456789abcdef0123456789abcdef")},
|
||||||
|
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "k1"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok, err := jwt.Signed(sig).Claims(c).Serialize()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
func noneToken(iss string) string {
|
||||||
|
enc := base64.RawURLEncoding.EncodeToString
|
||||||
|
return enc([]byte(`{"alg":"none","typ":"JWT","kid":"k1"}`)) + "." +
|
||||||
|
enc([]byte(`{"iss":"`+iss+`","sub":"ci","aud":"artifactapi","exp":`+strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)+`}`)) + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||||
|
|
||||||
|
func TestBearerOIDC(t *testing.T) {
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
defer iss.Close()
|
||||||
|
h := NewBearerOIDC(iss.URL, []string{"other", "artifactapi"}).Require(ok)
|
||||||
|
|
||||||
|
otherKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"valid", iss.Token(nil, "artifactapi", "ci", time.Hour), http.StatusNoContent},
|
||||||
|
{"missing", "", http.StatusUnauthorized},
|
||||||
|
{"garbage", "not.a.jwt", http.StatusUnauthorized},
|
||||||
|
{"bad signature", iss.Token(otherKey, "artifactapi", "ci", time.Hour), http.StatusUnauthorized},
|
||||||
|
{"wrong aud", iss.Token(nil, "nope", "ci", time.Hour), http.StatusUnauthorized},
|
||||||
|
{"expired", iss.Token(nil, "artifactapi", "ci", -time.Hour), http.StatusUnauthorized},
|
||||||
|
{"wrong iss", iss.Sign(nil, claims("https://evil.example")), http.StatusUnauthorized},
|
||||||
|
{"hs256", hs256Token(t, claims(iss.URL)), http.StatusUnauthorized},
|
||||||
|
{"alg none", noneToken(iss.URL), http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := serve(h, http.MethodPost, c.token); got != c.want {
|
||||||
|
t.Errorf("%s: status %d, want %d", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||||
|
headers := []struct {
|
||||||
|
name string
|
||||||
|
authz string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"lowercase scheme", "bearer " + valid, http.StatusNoContent},
|
||||||
|
{"extra whitespace", " Bearer " + valid + " ", http.StatusNoContent},
|
||||||
|
{"empty value", "Bearer ", http.StatusUnauthorized},
|
||||||
|
{"scheme only", "Bearer", http.StatusUnauthorized},
|
||||||
|
{"basic scheme", "Basic " + valid, http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
for _, c := range headers {
|
||||||
|
if got := serveAuth(h, http.MethodPost, c.authz); got != c.want {
|
||||||
|
t.Errorf("%s: status %d, want %d", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCHangingIssuerBacksOff(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
release := make(chan struct{})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
case <-release:
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
defer close(release)
|
||||||
|
|
||||||
|
b := NewBearerOIDC(srv.URL, nil)
|
||||||
|
b.timeout = 100 * time.Millisecond
|
||||||
|
b.backoff = time.Hour
|
||||||
|
h := b.Require(ok)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if got := serve(h, http.MethodPost, "x.y.z"); got != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("hanging issuer: status %d, want 503", got)
|
||||||
|
}
|
||||||
|
if d := time.Since(start); d > 2*time.Second {
|
||||||
|
t.Fatalf("hanging issuer: took %s, timeout not applied", d)
|
||||||
|
}
|
||||||
|
if got := serve(h, http.MethodPost, "x.y.z"); got != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("during backoff: status %d, want 503", got)
|
||||||
|
}
|
||||||
|
if n := hits.Load(); n != 1 {
|
||||||
|
t.Fatalf("issuer hit %d times, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCPanickingDiscoveryBacksOff(t *testing.T) {
|
||||||
|
b := NewBearerOIDC("http://issuer.invalid", nil)
|
||||||
|
b.backoff = time.Hour
|
||||||
|
b.discover = func(context.Context, string) (*oidc.Provider, error) { panic("boom") }
|
||||||
|
h := b.Require(ok)
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
serve(h, http.MethodPost, "x.y.z")
|
||||||
|
}()
|
||||||
|
|
||||||
|
got := make(chan int, 1)
|
||||||
|
go func() { got <- serve(h, http.MethodPost, "x.y.z") }()
|
||||||
|
select {
|
||||||
|
case code := <-got:
|
||||||
|
if code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("after panic: status %d, want 503", code)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("after panic: request hung on stale inflight discovery")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCConcurrentFirstRequestsWaitForDiscovery(t *testing.T) {
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
defer iss.Close()
|
||||||
|
var hits atomic.Int32
|
||||||
|
inner := iss.Config.Handler
|
||||||
|
iss.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/.well-known/openid-configuration" {
|
||||||
|
hits.Add(1)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
inner.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
h := NewBearerOIDC(iss.URL, nil).Require(ok)
|
||||||
|
tok := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
codes := make([]int, 20)
|
||||||
|
for i := range codes {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
codes[i] = serve(h, http.MethodPost, tok)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for i, c := range codes {
|
||||||
|
if c != http.StatusNoContent {
|
||||||
|
t.Errorf("request %d: status %d, want 204", i, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := hits.Load(); n != 1 {
|
||||||
|
t.Fatalf("discovery hit %d times, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCRetriesAfterBackoff(t *testing.T) {
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
defer iss.Close()
|
||||||
|
b := NewBearerOIDC(iss.URL, nil)
|
||||||
|
b.failedAt = time.Now()
|
||||||
|
b.backoff = 50 * time.Millisecond
|
||||||
|
tok := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||||
|
if got := serve(b.Require(ok), http.MethodPost, tok); got != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("within backoff: status %d, want 503", got)
|
||||||
|
}
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
if got := serve(b.Require(ok), http.MethodPost, tok); got != http.StatusNoContent {
|
||||||
|
t.Fatalf("after backoff: status %d, want 204", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCIssuerDown(t *testing.T) {
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
b := NewBearerOIDC(iss.URL, []string{"artifactapi"})
|
||||||
|
tok := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||||
|
iss.Close()
|
||||||
|
if got := serve(b.Require(ok), http.MethodPost, tok); got != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("issuer down: status %d, want 503", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCDisabled(t *testing.T) {
|
||||||
|
if got := serve(NewBearerOIDC("", nil).RequireWrites(ok), http.MethodDelete, ""); got != http.StatusNoContent {
|
||||||
|
t.Errorf("disabled: status %d, want 204", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireWritesLeavesReadsOpen(t *testing.T) {
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
defer iss.Close()
|
||||||
|
h := NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireWrites(ok)
|
||||||
|
for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
|
||||||
|
if got := serve(h, m, ""); got != http.StatusNoContent {
|
||||||
|
t.Errorf("%s: status %d, want 204", m, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||||||
|
if got := serve(h, m, ""); got != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s: status %d, want 401", m, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -56,6 +57,10 @@ type Config struct {
|
|||||||
GitHubAppInstallationID string
|
GitHubAppInstallationID string
|
||||||
GitHubAppPrivateKey string
|
GitHubAppPrivateKey string
|
||||||
GitHubAppPrivateKeyPath string
|
GitHubAppPrivateKeyPath string
|
||||||
|
|
||||||
|
// Bearer-JWT auth on configuration writes; empty issuer disables it.
|
||||||
|
OIDCIssuer string
|
||||||
|
OIDCAudiences []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) DatabaseDSN() string {
|
func (c *Config) DatabaseDSN() string {
|
||||||
@@ -90,6 +95,17 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("invalid GITHUB_SYNC_POLL_INTERVAL: %w", err)
|
return nil, fmt.Errorf("invalid GITHUB_SYNC_POLL_INTERVAL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oidcIssuer := getenv("ARTIFACTAPI_OIDC_ISSUER", "")
|
||||||
|
var oidcAudiences []string
|
||||||
|
for _, a := range strings.Split(getenv("ARTIFACTAPI_OIDC_AUDIENCE", ""), ",") {
|
||||||
|
if a = strings.TrimSpace(a); a != "" {
|
||||||
|
oidcAudiences = append(oidcAudiences, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oidcIssuer != "" && len(oidcAudiences) == 0 {
|
||||||
|
return nil, fmt.Errorf("ARTIFACTAPI_OIDC_AUDIENCE is required when ARTIFACTAPI_OIDC_ISSUER is set")
|
||||||
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ListenAddr: getenv("LISTEN_ADDR", ":8000"),
|
ListenAddr: getenv("LISTEN_ADDR", ":8000"),
|
||||||
|
|
||||||
@@ -123,6 +139,9 @@ func Load() (*Config, error) {
|
|||||||
GitHubAppInstallationID: getenv("GITHUB_APP_INSTALLATION_ID", ""),
|
GitHubAppInstallationID: getenv("GITHUB_APP_INSTALLATION_ID", ""),
|
||||||
GitHubAppPrivateKey: getenv("GITHUB_APP_PRIVATE_KEY", ""),
|
GitHubAppPrivateKey: getenv("GITHUB_APP_PRIVATE_KEY", ""),
|
||||||
GitHubAppPrivateKeyPath: getenv("GITHUB_APP_PRIVATE_KEY_PATH", ""),
|
GitHubAppPrivateKeyPath: getenv("GITHUB_APP_PRIVATE_KEY_PATH", ""),
|
||||||
|
|
||||||
|
OIDCIssuer: oidcIssuer,
|
||||||
|
OIDCAudiences: oidcAudiences,
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
@@ -64,3 +64,20 @@ func TestLoadInvalidPort(t *testing.T) {
|
|||||||
t.Error("expected error for invalid DBPORT")
|
t.Error("expected error for invalid DBPORT")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadOIDC(t *testing.T) {
|
||||||
|
t.Setenv("ARTIFACTAPI_OIDC_ISSUER", "https://vault.example/v1/identity/oidc")
|
||||||
|
t.Setenv("ARTIFACTAPI_OIDC_AUDIENCE", " a, ,b ")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.OIDCAudiences) != 2 || cfg.OIDCAudiences[0] != "a" || cfg.OIDCAudiences[1] != "b" {
|
||||||
|
t.Errorf("audiences = %q", cfg.OIDCAudiences)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("ARTIFACTAPI_OIDC_AUDIENCE", "")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Error("expected error for issuer without audience")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,42 +28,14 @@ func (db *DB) ListGitHubAlpineRemotes(ctx context.Context) ([]models.Remote, err
|
|||||||
return remotes, rows.Err()
|
return remotes, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimGitHubAlpineSyncLease atomically claims the per-remote sync lease. It
|
// ClaimGitHubAlpineSyncLease atomically claims the per-remote github_alpine sync
|
||||||
// succeeds only when the remote is due (never synced, or synced longer than
|
// lease. See claimSyncLease.
|
||||||
// freshness ago) and no live lease is held by another replica. A zero freshness
|
|
||||||
// (prime scans) ignores the recency gate. The returned etag is the stored
|
|
||||||
// releases-list ETag, shared across replicas.
|
|
||||||
func (db *DB) ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
func (db *DB) ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||||
row := db.Pool.QueryRow(ctx, `
|
return db.claimSyncLease(ctx, "github_alpine_sync_state", remoteName, owner, freshness, lease)
|
||||||
INSERT INTO github_alpine_sync_state AS s (remote_name, sync_lease_owner, sync_lease_expires)
|
|
||||||
VALUES ($1, $2, now() + make_interval(secs => $4))
|
|
||||||
ON CONFLICT (remote_name) DO UPDATE
|
|
||||||
SET sync_lease_owner = $2,
|
|
||||||
sync_lease_expires = now() + make_interval(secs => $4)
|
|
||||||
WHERE (s.last_synced_at IS NULL OR s.last_synced_at < now() - make_interval(secs => $3))
|
|
||||||
AND (s.sync_lease_expires IS NULL OR s.sync_lease_expires < now())
|
|
||||||
RETURNING s.etag
|
|
||||||
`, remoteName, owner, freshness.Seconds(), lease.Seconds())
|
|
||||||
|
|
||||||
var etag string
|
|
||||||
if err := row.Scan(&etag); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return false, "", nil
|
|
||||||
}
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
return true, etag, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseGitHubAlpineSyncLease records the completed scan and frees the lease.
|
// ReleaseGitHubAlpineSyncLease records a github_alpine scan outcome and frees the
|
||||||
// Only the owning replica may release; last_synced_at advances so the next poll
|
// lease. See releaseSyncLease.
|
||||||
// waits a full freshness window, and etag is persisted for the next conditional
|
func (db *DB) ReleaseGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error {
|
||||||
// request.
|
return db.releaseSyncLease(ctx, "github_alpine_sync_state", remoteName, owner, res)
|
||||||
func (db *DB) ReleaseGitHubAlpineSyncLease(ctx context.Context, remoteName, owner, etag string, syncedAt time.Time) error {
|
|
||||||
_, err := db.Pool.Exec(ctx, `
|
|
||||||
UPDATE github_alpine_sync_state
|
|
||||||
SET last_synced_at = $3, etag = $4, sync_lease_owner = '', sync_lease_expires = NULL
|
|
||||||
WHERE remote_name = $1 AND sync_lease_owner = $2
|
|
||||||
`, remoteName, owner, syncedAt, etag)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrCargoNameTaken = errors.New("crate name already taken")
|
||||||
|
|
||||||
|
func cargoNormName(name string) string {
|
||||||
|
return strings.ReplaceAll(strings.ToLower(name), "-", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertCargoIndexEntry rejects a name that differs from an existing crate only
|
||||||
|
// by case or -/_ (ErrCargoNameTaken) and a version equal up to build metadata
|
||||||
|
// (ErrAlreadyExists). A per-crate advisory lock serialises concurrent publishes.
|
||||||
|
func (db *DB) InsertCargoIndexEntry(ctx context.Context, e *provider.CargoIndexEntry) error {
|
||||||
|
tx, err := db.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
norm := cargoNormName(e.Name)
|
||||||
|
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('cargo:' || $1 || '/' || $2))`, e.RepoName, norm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT name, version FROM cargo_index
|
||||||
|
WHERE repo_name = $1 AND replace(lower(name), '-', '_') = $2
|
||||||
|
`, e.RepoName, norm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
base, _, _ := strings.Cut(e.Version, "+")
|
||||||
|
for rows.Next() {
|
||||||
|
var name, version string
|
||||||
|
if err := rows.Scan(&name, &version); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if name != e.Name {
|
||||||
|
return fmt.Errorf("%w: %q exists as %q", ErrCargoNameTaken, e.Name, name)
|
||||||
|
}
|
||||||
|
if v, _, _ := strings.Cut(version, "+"); v == base {
|
||||||
|
return fmt.Errorf("%w: %s@%s", ErrAlreadyExists, name, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO cargo_index (repo_name, file_path, name, version, line, yanked)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
`, e.RepoName, e.FilePath, e.Name, e.Version, e.Line, e.Yanked); err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return fmt.Errorf("%w: %s@%s", ErrAlreadyExists, e.Name, e.Version)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListCargoIndexEntries(ctx context.Context, repoName, name string) ([]provider.CargoIndexEntry, error) {
|
||||||
|
rows, err := db.Pool.Query(ctx, `
|
||||||
|
SELECT repo_name, file_path, name, version, line, yanked
|
||||||
|
FROM cargo_index
|
||||||
|
WHERE repo_name = $1 AND lower(name) = $2
|
||||||
|
ORDER BY id
|
||||||
|
`, repoName, strings.ToLower(name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []provider.CargoIndexEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e provider.CargoIndexEntry
|
||||||
|
if err := rows.Scan(&e.RepoName, &e.FilePath, &e.Name, &e.Version, &e.Line, &e.Yanked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCargoYanked reports false when no such crate version exists.
|
||||||
|
func (db *DB) SetCargoYanked(ctx context.Context, repoName, name, version string, yanked bool) (bool, error) {
|
||||||
|
tag, err := db.Pool.Exec(ctx, `
|
||||||
|
UPDATE cargo_index SET yanked = $4
|
||||||
|
WHERE repo_name = $1 AND lower(name) = $2 AND version = $3
|
||||||
|
`, repoName, strings.ToLower(name), version, yanked)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteCargoIndexEntry(ctx context.Context, repoName, filePath string) error {
|
||||||
|
_, err := db.Pool.Exec(ctx, `DELETE FROM cargo_index WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -425,3 +428,115 @@ func TestRPMMetadata(t *testing.T) {
|
|||||||
t.Fatalf("list rpm rows: %v %v", len(rows), err)
|
t.Fatalf("list rpm rows: %v %v", len(rows), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCargoIndex(t *testing.T) {
|
||||||
|
requireDB(t)
|
||||||
|
for _, v := range []string{"0.1.0", "0.2.0"} {
|
||||||
|
if err := testDB.InsertCargoIndexEntry(ctx(), &provider.CargoIndexEntry{
|
||||||
|
RepoName: "cargo-db", FilePath: "crates/Unkin-Hello/Unkin-Hello-" + v + ".crate",
|
||||||
|
Name: "Unkin-Hello", Version: v, Line: `{"vers":"` + v + `"}`,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := testDB.SetCargoYanked(ctx(), "cargo-db", "unkin-hello", "0.1.0", true)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("yank: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if found, _ := testDB.SetCargoYanked(ctx(), "cargo-db", "unkin-hello", "9.9.9", true); found {
|
||||||
|
t.Error("yank of a missing version reported found")
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := testDB.ListCargoIndexEntries(ctx(), "cargo-db", "unkin-hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 || entries[0].Version != "0.1.0" || !entries[0].Yanked || entries[1].Yanked {
|
||||||
|
t.Fatalf("entries = %+v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := testDB.DeleteCargoIndexEntry(ctx(), "cargo-db", entries[0].FilePath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if entries, _ = testDB.ListCargoIndexEntries(ctx(), "cargo-db", "UNKIN-HELLO"); len(entries) != 1 {
|
||||||
|
t.Fatalf("after delete: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCargoIndexPublishConflicts(t *testing.T) {
|
||||||
|
requireDB(t)
|
||||||
|
entry := func(name, version string) *provider.CargoIndexEntry {
|
||||||
|
return &provider.CargoIndexEntry{
|
||||||
|
RepoName: "cargo-conflict", FilePath: "crates/" + name + "/" + name + "-" + version + ".crate",
|
||||||
|
Name: name, Version: version, Line: "{}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := testDB.InsertCargoIndexEntry(ctx(), entry("foo-bar", "1.0.0+a")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, version string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"foo-bar", "1.0.0+b", ErrAlreadyExists},
|
||||||
|
{"foo-bar", "1.0.0", ErrAlreadyExists},
|
||||||
|
{"Foo-Bar", "2.0.0", ErrCargoNameTaken},
|
||||||
|
{"foo_bar", "2.0.0", ErrCargoNameTaken},
|
||||||
|
} {
|
||||||
|
if err := testDB.InsertCargoIndexEntry(ctx(), entry(tc.name, tc.version)); !errors.Is(err, tc.want) {
|
||||||
|
t.Errorf("%s@%s: err = %v, want %v", tc.name, tc.version, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := testDB.InsertCargoIndexEntry(ctx(), entry("foo-bar", "1.0.1")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCargoIndexConcurrentPublish(t *testing.T) {
|
||||||
|
requireDB(t)
|
||||||
|
names := []string{"Race", "race", "RACE", "race", "rAce", "RaCe", "racE", "race"}
|
||||||
|
errs := make(chan error, len(names))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, name := range names {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
errs <- testDB.InsertCargoIndexEntry(ctx(), &provider.CargoIndexEntry{
|
||||||
|
RepoName: "cargo-race", FilePath: fmt.Sprintf("crates/%s/%d.crate", name, i),
|
||||||
|
Name: name, Version: "1.0.0", Line: "{}",
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
ok := 0
|
||||||
|
for err := range errs {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
ok++
|
||||||
|
case !errors.Is(err, ErrAlreadyExists) && !errors.Is(err, ErrCargoNameTaken):
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok != 1 {
|
||||||
|
t.Fatalf("%d concurrent publishes succeeded, want 1", ok)
|
||||||
|
}
|
||||||
|
if entries, _ := testDB.ListCargoIndexEntries(ctx(), "cargo-race", "race"); len(entries) > 1 {
|
||||||
|
t.Fatalf("entries = %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCargoIndexUniqueIndex(t *testing.T) {
|
||||||
|
requireDB(t)
|
||||||
|
insert := func(name, version, path string) error {
|
||||||
|
_, err := testDB.Pool.Exec(ctx(), `INSERT INTO cargo_index (repo_name, file_path, name, version, line) VALUES ('cargo-uniq', $1, $2, $3, '{}')`, path, name, version)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := insert("a-b", "1.0.0+x", "p1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := insert("A_B", "1.0.0+y", "p2"); err == nil {
|
||||||
|
t.Fatal("unique index allowed a normalised duplicate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,41 +28,14 @@ func (db *DB) ListGitHubDebRemotes(ctx context.Context) ([]models.Remote, error)
|
|||||||
return remotes, rows.Err()
|
return remotes, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimGitHubDebSyncLease atomically claims the per-remote sync lease. It
|
// ClaimGitHubDebSyncLease atomically claims the per-remote github_deb sync
|
||||||
// succeeds only when the remote is due (never synced, or synced longer than
|
// lease. See claimSyncLease.
|
||||||
// freshness ago) and no live lease is held by another replica. A zero freshness
|
|
||||||
// (prime scans) ignores the recency gate. The returned etag is the stored
|
|
||||||
// releases-list ETag, shared across replicas.
|
|
||||||
func (db *DB) ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
func (db *DB) ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||||
row := db.Pool.QueryRow(ctx, `
|
return db.claimSyncLease(ctx, "github_deb_sync_state", remoteName, owner, freshness, lease)
|
||||||
INSERT INTO github_deb_sync_state AS s (remote_name, sync_lease_owner, sync_lease_expires)
|
|
||||||
VALUES ($1, $2, now() + make_interval(secs => $4))
|
|
||||||
ON CONFLICT (remote_name) DO UPDATE
|
|
||||||
SET sync_lease_owner = $2,
|
|
||||||
sync_lease_expires = now() + make_interval(secs => $4)
|
|
||||||
WHERE (s.last_synced_at IS NULL OR s.last_synced_at < now() - make_interval(secs => $3))
|
|
||||||
AND (s.sync_lease_expires IS NULL OR s.sync_lease_expires < now())
|
|
||||||
RETURNING s.etag
|
|
||||||
`, remoteName, owner, freshness.Seconds(), lease.Seconds())
|
|
||||||
|
|
||||||
var etag string
|
|
||||||
if err := row.Scan(&etag); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return false, "", nil
|
|
||||||
}
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
return true, etag, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseGitHubDebSyncLease records the completed scan and frees the lease. Only
|
// ReleaseGitHubDebSyncLease records a github_deb scan outcome and frees the
|
||||||
// the owning replica may release; last_synced_at advances so the next poll waits
|
// lease. See releaseSyncLease.
|
||||||
// a full freshness window, and etag is persisted for the next conditional request.
|
func (db *DB) ReleaseGitHubDebSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error {
|
||||||
func (db *DB) ReleaseGitHubDebSyncLease(ctx context.Context, remoteName, owner, etag string, syncedAt time.Time) error {
|
return db.releaseSyncLease(ctx, "github_deb_sync_state", remoteName, owner, res)
|
||||||
_, err := db.Pool.Exec(ctx, `
|
|
||||||
UPDATE github_deb_sync_state
|
|
||||||
SET last_synced_at = $3, etag = $4, sync_lease_owner = '', sync_lease_expires = NULL
|
|
||||||
WHERE remote_name = $1 AND sync_lease_owner = $2
|
|
||||||
`, remoteName, owner, syncedAt, etag)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ func TestGitHubDebSyncLease(t *testing.T) {
|
|||||||
t.Fatal("replica-2 claimed while replica-1 holds the lease")
|
t.Fatal("replica-2 claimed while replica-1 holds the lease")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := testDB.ReleaseGitHubDebSyncLease(ctx(), name, "replica-1", `"etag-1"`, time.Now()); err != nil {
|
if err := testDB.ReleaseGitHubDebSyncLease(ctx(), name, "replica-1", provider.SyncResult{Etag: `"etag-1"`}); err != nil {
|
||||||
t.Fatalf("release: %v", err)
|
t.Fatalf("release: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,22 +32,35 @@ func (db *DB) ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error)
|
|||||||
return remotes, rows.Err()
|
return remotes, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimGitHubSyncLease atomically claims the per-remote sync lease. It succeeds
|
// ClaimGitHubSyncLease atomically claims the per-remote github_rpm sync lease.
|
||||||
// (claimed=true) only when the remote is due — never synced, or synced longer
|
// See claimSyncLease.
|
||||||
// than freshness ago — and no live lease is held by another replica. This bounds
|
|
||||||
// total GitHub load to roughly one scan per freshness window regardless of how
|
|
||||||
// many replicas poll. The returned etag is the stored releases-list ETag, shared
|
|
||||||
// across replicas so a conditional request can short-circuit an unchanged repo.
|
|
||||||
// A zero freshness (used for prime scans) ignores the recency gate and claims
|
|
||||||
// whenever no live lease is held.
|
|
||||||
func (db *DB) ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
func (db *DB) ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||||
|
return db.claimSyncLease(ctx, "github_rpm_sync_state", remoteName, owner, freshness, lease)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseGitHubSyncLease records a github_rpm scan outcome and frees the lease.
|
||||||
|
// See releaseSyncLease.
|
||||||
|
func (db *DB) ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error {
|
||||||
|
return db.releaseSyncLease(ctx, "github_rpm_sync_state", remoteName, owner, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimSyncLease atomically claims a per-remote sync lease in table. It succeeds
|
||||||
|
// (claimed=true) only when the remote is due and no live lease is held by
|
||||||
|
// another replica. Due means: a pending retry after a failed scan has reached
|
||||||
|
// next_retry_at, or, with no retry pending, the remote was never synced or was
|
||||||
|
// synced longer than freshness ago. A zero freshness (prime scans) ignores the
|
||||||
|
// recency gate but still honours a pending retry's backoff. The returned etag is
|
||||||
|
// the stored releases-list ETag, shared across replicas so a conditional request
|
||||||
|
// can short-circuit an unchanged repo.
|
||||||
|
func (db *DB) claimSyncLease(ctx context.Context, table, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||||
row := db.Pool.QueryRow(ctx, `
|
row := db.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO github_rpm_sync_state AS s (remote_name, sync_lease_owner, sync_lease_expires)
|
INSERT INTO `+table+` AS s (remote_name, sync_lease_owner, sync_lease_expires)
|
||||||
VALUES ($1, $2, now() + make_interval(secs => $4))
|
VALUES ($1, $2, now() + make_interval(secs => $4))
|
||||||
ON CONFLICT (remote_name) DO UPDATE
|
ON CONFLICT (remote_name) DO UPDATE
|
||||||
SET sync_lease_owner = $2,
|
SET sync_lease_owner = $2,
|
||||||
sync_lease_expires = now() + make_interval(secs => $4)
|
sync_lease_expires = now() + make_interval(secs => $4)
|
||||||
WHERE (s.last_synced_at IS NULL OR s.last_synced_at < now() - make_interval(secs => $3))
|
WHERE (CASE WHEN s.next_retry_at IS NOT NULL THEN s.next_retry_at <= now()
|
||||||
|
ELSE s.last_synced_at IS NULL OR s.last_synced_at < now() - make_interval(secs => $3) END)
|
||||||
AND (s.sync_lease_expires IS NULL OR s.sync_lease_expires < now())
|
AND (s.sync_lease_expires IS NULL OR s.sync_lease_expires < now())
|
||||||
RETURNING s.etag
|
RETURNING s.etag
|
||||||
`, remoteName, owner, freshness.Seconds(), lease.Seconds())
|
`, remoteName, owner, freshness.Seconds(), lease.Seconds())
|
||||||
@@ -60,14 +75,27 @@ func (db *DB) ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string
|
|||||||
return true, etag, nil
|
return true, etag, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseGitHubSyncLease records the completed scan and frees the lease. Only the
|
// releaseSyncLease records a scan outcome and frees the lease; only the owning
|
||||||
// owning replica may release; last_synced_at advances so the next poll waits a
|
// replica may release. Success advances last_synced_at, persists the etag and
|
||||||
// full freshness window, and etag is persisted for the next conditional request.
|
// clears any retry. Failure leaves last_synced_at and etag untouched (the last
|
||||||
func (db *DB) ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner, etag string, syncedAt time.Time) error {
|
// good metadata keeps serving) and schedules next_retry_at with exponential
|
||||||
|
// backoff from the consecutive-failure count, never before res.RetryAt.
|
||||||
|
func (db *DB) releaseSyncLease(ctx context.Context, table, remoteName, owner string, res provider.SyncResult) error {
|
||||||
|
var retryAt *time.Time
|
||||||
|
if !res.RetryAt.IsZero() {
|
||||||
|
retryAt = &res.RetryAt
|
||||||
|
}
|
||||||
_, err := db.Pool.Exec(ctx, `
|
_, err := db.Pool.Exec(ctx, `
|
||||||
UPDATE github_rpm_sync_state
|
UPDATE `+table+`
|
||||||
SET last_synced_at = $3, etag = $4, sync_lease_owner = '', sync_lease_expires = NULL
|
SET last_synced_at = CASE WHEN $3 THEN last_synced_at ELSE now() END,
|
||||||
|
etag = CASE WHEN $3 THEN etag ELSE $4 END,
|
||||||
|
sync_failures = CASE WHEN $3 THEN sync_failures + 1 ELSE 0 END,
|
||||||
|
next_retry_at = CASE WHEN $3 THEN GREATEST(
|
||||||
|
now() + make_interval(secs => LEAST($5 * power(2, LEAST(sync_failures, 20)), $6)),
|
||||||
|
$7::timestamptz)
|
||||||
|
END,
|
||||||
|
sync_lease_owner = '', sync_lease_expires = NULL
|
||||||
WHERE remote_name = $1 AND sync_lease_owner = $2
|
WHERE remote_name = $1 AND sync_lease_owner = $2
|
||||||
`, remoteName, owner, syncedAt, etag)
|
`, remoteName, owner, res.Failed, res.Etag, res.Backoff.Seconds(), res.MaxBackoff.Seconds(), retryAt)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ func TestGitHubSyncLease(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Replica 1 finishes: record the sync and persist an etag.
|
// Replica 1 finishes: record the sync and persist an etag.
|
||||||
if err := testDB.ReleaseGitHubSyncLease(ctx(), name, "replica-1", `"etag-1"`, time.Now()); err != nil {
|
if err := testDB.ReleaseGitHubSyncLease(ctx(), name, "replica-1", provider.SyncResult{Etag: `"etag-1"`}); err != nil {
|
||||||
t.Fatalf("release: %v", err)
|
t.Fatalf("release: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,3 +94,116 @@ func TestListGitHubRPMRemotes(t *testing.T) {
|
|||||||
t.Fatalf("seeded remote %q not returned", name)
|
t.Fatalf("seeded remote %q not returned", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A failed scan must not count as a sync: the etag and last_synced_at stay put,
|
||||||
|
// every claim (prime included) is held off until the backoff elapses, the delay
|
||||||
|
// doubles per consecutive failure up to the cap, an upstream retry hint pushes
|
||||||
|
// it later, and a success clears the retry state.
|
||||||
|
func TestGitHubSyncLeaseFailureBackoff(t *testing.T) {
|
||||||
|
requireDB(t)
|
||||||
|
name := "gh-retry-" + time.Now().Format("150405.000000")
|
||||||
|
seedGitHubRPMRemote(t, name)
|
||||||
|
|
||||||
|
const lease = 15 * time.Minute
|
||||||
|
freshness := time.Hour
|
||||||
|
claim := func(f time.Duration) (bool, string) {
|
||||||
|
t.Helper()
|
||||||
|
ok, etag, err := testDB.ClaimGitHubSyncLease(ctx(), name, "r1", f, lease)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim: %v", err)
|
||||||
|
}
|
||||||
|
return ok, etag
|
||||||
|
}
|
||||||
|
release := func(res provider.SyncResult) {
|
||||||
|
t.Helper()
|
||||||
|
if err := testDB.ReleaseGitHubSyncLease(ctx(), name, "r1", res); err != nil {
|
||||||
|
t.Fatalf("release: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state := func() (failures int, retryIn time.Duration, synced *time.Time, etag string) {
|
||||||
|
t.Helper()
|
||||||
|
var next *time.Time
|
||||||
|
var dbNow time.Time
|
||||||
|
if err := testDB.Pool.QueryRow(ctx(), `SELECT sync_failures, next_retry_at, last_synced_at, etag, now() FROM github_rpm_sync_state WHERE remote_name = $1`, name).
|
||||||
|
Scan(&failures, &next, &synced, &etag, &dbNow); err != nil {
|
||||||
|
t.Fatalf("read state: %v", err)
|
||||||
|
}
|
||||||
|
if next != nil {
|
||||||
|
retryIn = next.Sub(dbNow)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elapse := func() {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := testDB.Pool.Exec(ctx(), `UPDATE github_rpm_sync_state SET next_retry_at = now() - interval '1 second' WHERE remote_name = $1`, name); err != nil {
|
||||||
|
t.Fatalf("elapse backoff: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failed := provider.SyncResult{Failed: true, Etag: `"ignored"`, Backoff: time.Minute, MaxBackoff: 3 * time.Minute}
|
||||||
|
|
||||||
|
// Last good sync.
|
||||||
|
if ok, _ := claim(freshness); !ok {
|
||||||
|
t.Fatal("first claim failed")
|
||||||
|
}
|
||||||
|
release(provider.SyncResult{Etag: `"good"`})
|
||||||
|
_, _, goodSynced, _ := state()
|
||||||
|
|
||||||
|
// First failure: 60s backoff, sync time and etag untouched.
|
||||||
|
if ok, _ := claim(0); !ok {
|
||||||
|
t.Fatal("prime claim failed")
|
||||||
|
}
|
||||||
|
release(failed)
|
||||||
|
n, in, synced, etag := state()
|
||||||
|
if n != 1 || in < 55*time.Second || in > 65*time.Second {
|
||||||
|
t.Fatalf("after 1st failure: failures=%d retry in %v, want 1 and ~60s", n, in)
|
||||||
|
}
|
||||||
|
if etag != `"good"` || synced == nil || !synced.Equal(*goodSynced) {
|
||||||
|
t.Fatalf("failed scan overwrote sync state: etag=%q synced=%v", etag, synced)
|
||||||
|
}
|
||||||
|
if ok, _ := claim(0); ok {
|
||||||
|
t.Fatal("prime claimed inside the retry backoff")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backoff elapsed: claimable despite being inside the freshness window,
|
||||||
|
// and the second failure doubles the delay.
|
||||||
|
elapse()
|
||||||
|
if ok, _ := claim(freshness); !ok {
|
||||||
|
t.Fatal("periodic claim refused after backoff elapsed")
|
||||||
|
}
|
||||||
|
release(failed)
|
||||||
|
if n, in, _, _ := state(); n != 2 || in < 115*time.Second || in > 125*time.Second {
|
||||||
|
t.Fatalf("after 2nd failure: failures=%d retry in %v, want 2 and ~120s", n, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third failure caps at MaxBackoff (3m, not 4m).
|
||||||
|
elapse()
|
||||||
|
claim(freshness)
|
||||||
|
release(failed)
|
||||||
|
if n, in, _, _ := state(); n != 3 || in < 175*time.Second || in > 185*time.Second {
|
||||||
|
t.Fatalf("after 3rd failure: failures=%d retry in %v, want 3 and ~180s", n, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An upstream rate-limit reset later than the backoff wins.
|
||||||
|
elapse()
|
||||||
|
claim(freshness)
|
||||||
|
hinted := failed
|
||||||
|
hinted.RetryAt = time.Now().Add(20 * time.Minute)
|
||||||
|
release(hinted)
|
||||||
|
if _, in, _, _ := state(); in < 19*time.Minute || in > 21*time.Minute {
|
||||||
|
t.Fatalf("retry hint ignored: retry in %v, want ~20m", in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recovery: success persists the new etag and clears the retry, so the
|
||||||
|
// normal freshness window applies again.
|
||||||
|
elapse()
|
||||||
|
if ok, etag := claim(freshness); !ok || etag != `"good"` {
|
||||||
|
t.Fatalf("recovery claim ok=%v etag=%q, want last good etag", ok, etag)
|
||||||
|
}
|
||||||
|
release(provider.SyncResult{Etag: `"new"`})
|
||||||
|
if n, in, _, etag := state(); n != 0 || in != 0 || etag != `"new"` {
|
||||||
|
t.Fatalf("after success: failures=%d retry in %v etag=%q", n, in, etag)
|
||||||
|
}
|
||||||
|
if ok, _ := claim(freshness); ok {
|
||||||
|
t.Fatal("claimed inside freshness window after a successful sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ func (p *GitHubProvider) fetchReleases(ctx context.Context, remote models.Remote
|
|||||||
return nil, "", false, err
|
return nil, "", false, err
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, "", false, fmt.Errorf("github releases API %s: status %d", u, resp.StatusCode)
|
return nil, "", false, provider.NewUpstreamStatusError("github releases API "+u, resp)
|
||||||
}
|
}
|
||||||
if page == 1 {
|
if page == 1 {
|
||||||
newEtag = respEtag
|
newEtag = respEtag
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type SyncStore interface {
|
|||||||
provider.RemoteMetadataStore
|
provider.RemoteMetadataStore
|
||||||
ListGitHubAlpineRemotes(ctx context.Context) ([]models.Remote, error)
|
ListGitHubAlpineRemotes(ctx context.Context) ([]models.Remote, error)
|
||||||
ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
||||||
ReleaseGitHubAlpineSyncLease(ctx context.Context, remoteName, owner, etag string, syncedAt time.Time) error
|
ReleaseGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncConfig tunes the shared syncer. Zero values fall back to safe defaults.
|
// SyncConfig tunes the shared syncer. Zero values fall back to safe defaults.
|
||||||
@@ -189,10 +189,11 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
freshness := time.Duration(job.remote.MutableTTL) * time.Second
|
ttl := time.Duration(job.remote.MutableTTL) * time.Second
|
||||||
if freshness <= 0 {
|
if ttl <= 0 {
|
||||||
freshness = defaultSyncFreshness
|
ttl = defaultSyncFreshness
|
||||||
}
|
}
|
||||||
|
freshness := ttl
|
||||||
if job.prime {
|
if job.prime {
|
||||||
freshness = 0
|
freshness = 0
|
||||||
}
|
}
|
||||||
@@ -210,16 +211,13 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
newEtag, changed, scanErr := s.prov.scanWithState(scanCtx, job.remote, s.store, etag)
|
newEtag, changed, scanErr := s.prov.scanWithState(scanCtx, job.remote, s.store, etag)
|
||||||
releaseEtag := etag
|
if scanErr != nil {
|
||||||
if scanErr == nil {
|
|
||||||
releaseEtag = newEtag
|
|
||||||
} else {
|
|
||||||
slog.Error("github_alpine syncer: scan failed", "remote", job.remote.Name, "error", scanErr)
|
slog.Error("github_alpine syncer: scan failed", "remote", job.remote.Name, "error", scanErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
relCtx, relCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
relCtx, relCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||||
defer relCancel()
|
defer relCancel()
|
||||||
if err := s.store.ReleaseGitHubAlpineSyncLease(relCtx, job.remote.Name, s.owner, releaseEtag, time.Now()); err != nil {
|
if err := s.store.ReleaseGitHubAlpineSyncLease(relCtx, job.remote.Name, s.owner, provider.NewSyncResult(newEtag, scanErr, ttl)); err != nil {
|
||||||
slog.Warn("github_alpine syncer: release lease", "remote", job.remote.Name, "error", err)
|
slog.Warn("github_alpine syncer: release lease", "remote", job.remote.Name, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type fakeSyncStore struct {
|
|||||||
leaseExp map[string]time.Time
|
leaseExp map[string]time.Time
|
||||||
lastSynced map[string]time.Time
|
lastSynced map[string]time.Time
|
||||||
etags map[string]string
|
etags map[string]string
|
||||||
|
retryAt map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeSyncStore() *fakeSyncStore {
|
func newFakeSyncStore() *fakeSyncStore {
|
||||||
@@ -36,6 +37,7 @@ func newFakeSyncStore() *fakeSyncStore {
|
|||||||
leaseExp: map[string]time.Time{},
|
leaseExp: map[string]time.Time{},
|
||||||
lastSynced: map[string]time.Time{},
|
lastSynced: map[string]time.Time{},
|
||||||
etags: map[string]string{},
|
etags: map[string]string{},
|
||||||
|
retryAt: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,9 @@ func (f *fakeSyncStore) ClaimGitHubAlpineSyncLease(_ context.Context, name, owne
|
|||||||
ls, hasLS := f.lastSynced[name]
|
ls, hasLS := f.lastSynced[name]
|
||||||
exp, hasExp := f.leaseExp[name]
|
exp, hasExp := f.leaseExp[name]
|
||||||
freshOK := !hasLS || now.Sub(ls) >= freshness
|
freshOK := !hasLS || now.Sub(ls) >= freshness
|
||||||
|
if ra, pending := f.retryAt[name]; pending {
|
||||||
|
freshOK = !now.Before(ra)
|
||||||
|
}
|
||||||
leaseOK := !hasExp || exp.Before(now)
|
leaseOK := !hasExp || exp.Before(now)
|
||||||
if freshOK && leaseOK {
|
if freshOK && leaseOK {
|
||||||
f.leaseOwner[name] = owner
|
f.leaseOwner[name] = owner
|
||||||
@@ -61,14 +66,19 @@ func (f *fakeSyncStore) ClaimGitHubAlpineSyncLease(_ context.Context, name, owne
|
|||||||
return false, "", nil
|
return false, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSyncStore) ReleaseGitHubAlpineSyncLease(_ context.Context, name, owner, etag string, syncedAt time.Time) error {
|
func (f *fakeSyncStore) ReleaseGitHubAlpineSyncLease(_ context.Context, name, owner string, res provider.SyncResult) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
if f.leaseOwner[name] != owner {
|
if f.leaseOwner[name] != owner {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
f.lastSynced[name] = syncedAt
|
if res.Failed {
|
||||||
f.etags[name] = etag
|
f.retryAt[name] = time.Now().Add(res.Backoff)
|
||||||
|
} else {
|
||||||
|
f.lastSynced[name] = time.Now()
|
||||||
|
f.etags[name] = res.Etag
|
||||||
|
delete(f.retryAt, name)
|
||||||
|
}
|
||||||
delete(f.leaseOwner, name)
|
delete(f.leaseOwner, name)
|
||||||
delete(f.leaseExp, name)
|
delete(f.leaseExp, name)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Package cargo proxies a Cargo sparse registry (RFC 2789): mutable index
|
||||||
|
// files plus immutable .crate downloads, with config.json synthesized so
|
||||||
|
// cargo fetches crates back through artifactapi.
|
||||||
|
package cargo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
provider.Register(&Provider{})
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
crateDir = "crates/"
|
||||||
|
cratesIOIndex = "index.crates.io"
|
||||||
|
cratesIODownload = "https://static.crates.io"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider struct{}
|
||||||
|
|
||||||
|
func (p *Provider) Type() models.PackageType { return models.PackageCargo }
|
||||||
|
|
||||||
|
func (p *Provider) Classify(path string) provider.Mutability {
|
||||||
|
if isCrate(path) {
|
||||||
|
return provider.Immutable
|
||||||
|
}
|
||||||
|
return provider.Mutable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) ContentType(path string) string {
|
||||||
|
if isCrate(path) {
|
||||||
|
return "application/gzip"
|
||||||
|
}
|
||||||
|
if path == "config.json" {
|
||||||
|
return "application/json"
|
||||||
|
}
|
||||||
|
return "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpstreamURL maps crates/{crate}/{crate}-{version}.crate to the download host
|
||||||
|
// and everything else to the index. crates.io splits the two across hosts;
|
||||||
|
// any other upstream is expected to serve both.
|
||||||
|
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||||
|
path = strings.TrimLeft(path, "/")
|
||||||
|
base := strings.TrimRight(remote.BaseURL, "/")
|
||||||
|
if isCrate(path) {
|
||||||
|
if u, err := url.Parse(base); err == nil && u.Host == cratesIOIndex {
|
||||||
|
base = cratesIODownload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base + "/" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
|
||||||
|
return auth.BasicHeaders(remote), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeRemote answers config.json itself: dl must name the requesting host,
|
||||||
|
// which a cached upstream body cannot.
|
||||||
|
func (p *Provider) ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, _ provider.RemoteMetadataStore) bool {
|
||||||
|
if strings.TrimLeft(path, "/") != "config.json" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(Config(remote, proxyBaseURL))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config builds the sparse registry config.json pointing downloads at proxyBaseURL.
|
||||||
|
func Config(remote models.Remote, proxyBaseURL string) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"dl": strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name + "/" + crateDir + "{crate}/{crate}-{version}.crate",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCrate(path string) bool {
|
||||||
|
path = strings.TrimLeft(path, "/")
|
||||||
|
return strings.HasPrefix(path, crateDir) && strings.HasSuffix(path, ".crate")
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package cargo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassify(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want provider.Mutability
|
||||||
|
}{
|
||||||
|
{"crates/serde/serde-1.0.200.crate", provider.Immutable},
|
||||||
|
{"se/rd/serde", provider.Mutable},
|
||||||
|
{"1/a", provider.Mutable},
|
||||||
|
{"3/a/anyhow", provider.Mutable},
|
||||||
|
{"config.json", provider.Mutable},
|
||||||
|
{"cr/at/crates", provider.Mutable},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := p.Classify(tt.path); got != tt.want {
|
||||||
|
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamURL(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
cratesIO := models.Remote{BaseURL: "https://index.crates.io/"}
|
||||||
|
mirror := models.Remote{BaseURL: "http://mirror.example"}
|
||||||
|
tests := []struct {
|
||||||
|
remote models.Remote
|
||||||
|
path, want string
|
||||||
|
}{
|
||||||
|
{cratesIO, "se/rd/serde", "https://index.crates.io/se/rd/serde"},
|
||||||
|
{cratesIO, "crates/serde/serde-1.0.200.crate", "https://static.crates.io/crates/serde/serde-1.0.200.crate"},
|
||||||
|
{mirror, "crates/serde/serde-1.0.200.crate", "http://mirror.example/crates/serde/serde-1.0.200.crate"},
|
||||||
|
{mirror, "/2/ab", "http://mirror.example/2/ab"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := p.UpstreamURL(tt.remote, tt.path); got != tt.want {
|
||||||
|
t.Errorf("UpstreamURL(%q, %q) = %q, want %q", tt.remote.BaseURL, tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeRemoteConfig(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
remote := models.Remote{Name: "crates-io", BaseURL: "https://index.crates.io"}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
if !p.ServeRemote(w, httptest.NewRequest("GET", "/", nil), remote, "config.json", "https://aa.example/", nil) {
|
||||||
|
t.Fatal("config.json not served")
|
||||||
|
}
|
||||||
|
var cfg map[string]string
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "https://aa.example/api/v1/remote/crates-io/crates/{crate}/{crate}-{version}.crate"
|
||||||
|
if cfg["dl"] != want {
|
||||||
|
t.Errorf("dl = %q, want %q", cfg["dl"], want)
|
||||||
|
}
|
||||||
|
if _, ok := cfg["api"]; ok {
|
||||||
|
t.Error("api must be omitted: publishing through the proxy is unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.ServeRemote(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil), remote, "se/rd/serde", "https://aa.example", nil) {
|
||||||
|
t.Error("index files must fall through to the proxy engine")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package cargo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// publishDep is a dependency as cargo sends it in the publish metadata.
|
||||||
|
type publishDep struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
VersionReq string `json:"version_req"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
Optional bool `json:"optional"`
|
||||||
|
DefaultFeatures bool `json:"default_features"`
|
||||||
|
Target *string `json:"target"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Registry *string `json:"registry"`
|
||||||
|
ExplicitNameInToml *string `json:"explicit_name_in_toml"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishMeta is the JSON half of a `PUT /api/v1/crates/new` body; fields the
|
||||||
|
// index does not carry are ignored.
|
||||||
|
type PublishMeta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Vers string `json:"vers"`
|
||||||
|
Deps []publishDep `json:"deps"`
|
||||||
|
Features map[string][]string `json:"features"`
|
||||||
|
Links *string `json:"links"`
|
||||||
|
RustVersion *string `json:"rust_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type indexDep struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Req string `json:"req"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
Optional bool `json:"optional"`
|
||||||
|
DefaultFeatures bool `json:"default_features"`
|
||||||
|
Target *string `json:"target"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Registry *string `json:"registry,omitempty"`
|
||||||
|
Package *string `json:"package,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexLine is one line of a sparse index file.
|
||||||
|
type IndexLine struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Vers string `json:"vers"`
|
||||||
|
Deps []indexDep `json:"deps"`
|
||||||
|
Cksum string `json:"cksum"`
|
||||||
|
Features map[string][]string `json:"features"`
|
||||||
|
Features2 map[string][]string `json:"features2,omitempty"`
|
||||||
|
Yanked bool `json:"yanked"`
|
||||||
|
Links *string `json:"links,omitempty"`
|
||||||
|
V int `json:"v,omitempty"`
|
||||||
|
RustVersion *string `json:"rust_version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePublish splits a publish body: u32le JSON length, JSON, u32le crate length, crate.
|
||||||
|
func ParsePublish(body []byte) (PublishMeta, []byte, error) {
|
||||||
|
var meta PublishMeta
|
||||||
|
jsonBytes, rest, err := lengthPrefixed(body)
|
||||||
|
if err != nil {
|
||||||
|
return meta, nil, fmt.Errorf("metadata: %w", err)
|
||||||
|
}
|
||||||
|
crate, rest, err := lengthPrefixed(rest)
|
||||||
|
if err != nil {
|
||||||
|
return meta, nil, fmt.Errorf("crate: %w", err)
|
||||||
|
}
|
||||||
|
if len(rest) != 0 {
|
||||||
|
return meta, nil, errors.New("trailing bytes after crate")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(jsonBytes, &meta); err != nil {
|
||||||
|
return meta, nil, fmt.Errorf("metadata: %w", err)
|
||||||
|
}
|
||||||
|
return meta, crate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lengthPrefixed(b []byte) ([]byte, []byte, error) {
|
||||||
|
if len(b) < 4 {
|
||||||
|
return nil, nil, errors.New("truncated length")
|
||||||
|
}
|
||||||
|
n := binary.LittleEndian.Uint32(b)
|
||||||
|
b = b[4:]
|
||||||
|
if uint64(n) > uint64(len(b)) {
|
||||||
|
return nil, nil, errors.New("truncated body")
|
||||||
|
}
|
||||||
|
return b[:n], b[n:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIndexLine validates the metadata and translates it into index form.
|
||||||
|
func NewIndexLine(meta PublishMeta, crate []byte) (IndexLine, error) {
|
||||||
|
if !validName(meta.Name) {
|
||||||
|
return IndexLine{}, fmt.Errorf("invalid crate name %q", meta.Name)
|
||||||
|
}
|
||||||
|
if !validVersion(meta.Vers) {
|
||||||
|
return IndexLine{}, fmt.Errorf("invalid version %q", meta.Vers)
|
||||||
|
}
|
||||||
|
if len(crate) == 0 {
|
||||||
|
return IndexLine{}, errors.New("empty .crate")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(crate)
|
||||||
|
line := IndexLine{
|
||||||
|
Name: meta.Name,
|
||||||
|
Vers: meta.Vers,
|
||||||
|
Deps: make([]indexDep, 0, len(meta.Deps)),
|
||||||
|
Cksum: hex.EncodeToString(sum[:]),
|
||||||
|
Features: map[string][]string{},
|
||||||
|
Links: meta.Links,
|
||||||
|
RustVersion: meta.RustVersion,
|
||||||
|
}
|
||||||
|
for _, d := range meta.Deps {
|
||||||
|
dep := indexDep{
|
||||||
|
Name: d.Name,
|
||||||
|
Req: d.VersionReq,
|
||||||
|
Features: d.Features,
|
||||||
|
Optional: d.Optional,
|
||||||
|
DefaultFeatures: d.DefaultFeatures,
|
||||||
|
Target: d.Target,
|
||||||
|
Kind: d.Kind,
|
||||||
|
Registry: d.Registry,
|
||||||
|
}
|
||||||
|
if dep.Features == nil {
|
||||||
|
dep.Features = []string{}
|
||||||
|
}
|
||||||
|
if dep.Kind == "" {
|
||||||
|
dep.Kind = "normal"
|
||||||
|
}
|
||||||
|
if d.ExplicitNameInToml != nil && *d.ExplicitNameInToml != "" {
|
||||||
|
pkg := d.Name
|
||||||
|
dep.Name, dep.Package = *d.ExplicitNameInToml, &pkg
|
||||||
|
}
|
||||||
|
line.Deps = append(line.Deps, dep)
|
||||||
|
}
|
||||||
|
// Older cargo cannot parse dep:/?/ feature syntax, so those features live in
|
||||||
|
// features2 behind v:2, as on crates.io.
|
||||||
|
for name, vals := range meta.Features {
|
||||||
|
if vals == nil {
|
||||||
|
vals = []string{}
|
||||||
|
}
|
||||||
|
if usesNewFeatureSyntax(vals) {
|
||||||
|
if line.Features2 == nil {
|
||||||
|
line.Features2 = map[string][]string{}
|
||||||
|
}
|
||||||
|
line.Features2[name] = vals
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line.Features[name] = vals
|
||||||
|
}
|
||||||
|
if line.Features2 != nil {
|
||||||
|
line.V = 2
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func usesNewFeatureSyntax(vals []string) bool {
|
||||||
|
for _, v := range vals {
|
||||||
|
if strings.HasPrefix(v, "dep:") || strings.Contains(v, "?/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validName(name string) bool {
|
||||||
|
if name == "" || len(name) > 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, c := range name {
|
||||||
|
alpha := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||||
|
if i == 0 && !alpha {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !alpha && (c < '0' || c > '9') && c != '-' && c != '_' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// semverRe is the SemVer 2.0.0 grammar from semver.org.
|
||||||
|
var semverRe = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`)
|
||||||
|
|
||||||
|
func validVersion(v string) bool {
|
||||||
|
return len(v) <= 128 && semverRe.MatchString(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexPath is the sparse-index file path for a crate name.
|
||||||
|
func IndexPath(name string) string {
|
||||||
|
n := strings.ToLower(name)
|
||||||
|
switch len(n) {
|
||||||
|
case 0:
|
||||||
|
return ""
|
||||||
|
case 1:
|
||||||
|
return "1/" + n
|
||||||
|
case 2:
|
||||||
|
return "2/" + n
|
||||||
|
case 3:
|
||||||
|
return "3/" + n[:1] + "/" + n
|
||||||
|
}
|
||||||
|
return n[:2] + "/" + n[2:4] + "/" + n
|
||||||
|
}
|
||||||
|
|
||||||
|
// CratePath is where a published .crate is stored, matching config.json's dl.
|
||||||
|
func CratePath(name, version string) string {
|
||||||
|
return crateDir + name + "/" + name + "-" + version + ".crate"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderIndex joins stored lines into an index file with each yanked flag applied.
|
||||||
|
func RenderIndex(entries []provider.CargoIndexEntry) ([]byte, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, e := range entries {
|
||||||
|
var line IndexLine
|
||||||
|
if err := json.Unmarshal([]byte(e.Line), &line); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s %s: %w", e.Name, e.Version, err)
|
||||||
|
}
|
||||||
|
line.Yanked = e.Yanked
|
||||||
|
raw, err := json.Marshal(line)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.Write(raw)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return []byte(b.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalConfig is config.json for a local registry rooted at base.
|
||||||
|
func LocalConfig(base string) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"dl": base + "/" + crateDir + "{crate}/{crate}-{version}.crate",
|
||||||
|
"api": base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool {
|
||||||
|
path = strings.TrimLeft(path, "/")
|
||||||
|
if path == "config.json" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(LocalConfig(requestBase(r) + "/api/v1/local/" + repoName))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
name := path[strings.LastIndex(path, "/")+1:]
|
||||||
|
if name == "" || IndexPath(name) != path {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, ok := files.(provider.CargoIndexReader)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "cargo index not available", http.StatusInternalServerError)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
entries, err := reader.ListCargoIndexEntries(r.Context(), repoName, name)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
body, err := RenderIndex(entries)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("cargo local index generation for virtual repos not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) AfterDelete(ctx context.Context, repoName, storagePath string, db provider.MetadataDeleter) error {
|
||||||
|
deleter, ok := db.(provider.CargoIndexDeleter)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := deleter.DeleteCargoIndexEntry(ctx, repoName, storagePath); err != nil {
|
||||||
|
slog.Error("cargo index: delete failed", "repo", repoName, "path", storagePath, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestBase(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
} else if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
|
||||||
|
scheme = fwd
|
||||||
|
}
|
||||||
|
return scheme + "://" + r.Host
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package cargo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func publishBody(meta string, crate []byte) []byte {
|
||||||
|
var b []byte
|
||||||
|
b = binary.LittleEndian.AppendUint32(b, uint32(len(meta)))
|
||||||
|
b = append(b, meta...)
|
||||||
|
b = binary.LittleEndian.AppendUint32(b, uint32(len(crate)))
|
||||||
|
return append(b, crate...)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleMeta = `{
|
||||||
|
"name": "unkin-hello", "vers": "0.2.0",
|
||||||
|
"deps": [
|
||||||
|
{"name": "serde", "version_req": "^1", "features": ["derive"], "optional": false,
|
||||||
|
"default_features": true, "target": null, "kind": "normal",
|
||||||
|
"registry": "https://github.com/rust-lang/crates.io-index"},
|
||||||
|
{"name": "rand_core", "version_req": "^0.6", "features": null, "optional": true,
|
||||||
|
"default_features": false, "target": "cfg(unix)", "kind": "dev",
|
||||||
|
"explicit_name_in_toml": "rc"}
|
||||||
|
],
|
||||||
|
"features": {"default": ["std"], "std": [], "extra": ["dep:rc"], "weak": ["serde?/std"]},
|
||||||
|
"links": null, "rust_version": "1.70", "description": "ignored"
|
||||||
|
}`
|
||||||
|
|
||||||
|
func TestParsePublishAndIndexLine(t *testing.T) {
|
||||||
|
crate := []byte("fake crate bytes")
|
||||||
|
meta, gotCrate, err := ParsePublish(publishBody(sampleMeta, crate))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(gotCrate) != string(crate) {
|
||||||
|
t.Fatalf("crate = %q", gotCrate)
|
||||||
|
}
|
||||||
|
line, err := NewIndexLine(meta, crate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(crate)
|
||||||
|
if line.Cksum != hex.EncodeToString(sum[:]) {
|
||||||
|
t.Errorf("cksum = %s", line.Cksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(line)
|
||||||
|
var got map[string]any
|
||||||
|
_ = json.Unmarshal(raw, &got)
|
||||||
|
if _, ok := got["version_req"]; ok {
|
||||||
|
t.Error("publish-only field leaked into index line")
|
||||||
|
}
|
||||||
|
if got["v"] != float64(2) {
|
||||||
|
t.Errorf("v = %v, want 2 with features2 present", got["v"])
|
||||||
|
}
|
||||||
|
if got["rust_version"] != "1.70" {
|
||||||
|
t.Errorf("rust_version = %v", got["rust_version"])
|
||||||
|
}
|
||||||
|
if _, ok := line.Features["extra"]; ok {
|
||||||
|
t.Error("dep: feature must move to features2")
|
||||||
|
}
|
||||||
|
if len(line.Features2) != 2 || len(line.Features) != 2 {
|
||||||
|
t.Errorf("features = %v features2 = %v", line.Features, line.Features2)
|
||||||
|
}
|
||||||
|
|
||||||
|
deps := got["deps"].([]any)
|
||||||
|
serde := deps[0].(map[string]any)
|
||||||
|
if serde["req"] != "^1" || serde["registry"] != "https://github.com/rust-lang/crates.io-index" || serde["target"] != nil {
|
||||||
|
t.Errorf("serde dep = %v", serde)
|
||||||
|
}
|
||||||
|
if _, ok := serde["package"]; ok {
|
||||||
|
t.Error("package set without a rename")
|
||||||
|
}
|
||||||
|
renamed := deps[1].(map[string]any)
|
||||||
|
if renamed["name"] != "rc" || renamed["package"] != "rand_core" || renamed["kind"] != "dev" {
|
||||||
|
t.Errorf("renamed dep = %v", renamed)
|
||||||
|
}
|
||||||
|
if f, ok := renamed["features"].([]any); !ok || len(f) != 0 {
|
||||||
|
t.Errorf("null features must render as [], got %v", renamed["features"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexLineNoNewFeaturesOmitsV(t *testing.T) {
|
||||||
|
line, err := NewIndexLine(PublishMeta{Name: "a", Vers: "1.0.0", Features: map[string][]string{"x": nil}}, []byte("x"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(line)
|
||||||
|
s := string(raw)
|
||||||
|
for _, bad := range []string{`"v":`, `"features2"`, `"links"`} {
|
||||||
|
if strings.Contains(s, bad) {
|
||||||
|
t.Errorf("%s present in %s", bad, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"deps":[]`) || !strings.Contains(s, `"features":{"x":[]}`) {
|
||||||
|
t.Errorf("deps/features must be non-null: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePublishRejectsMalformed(t *testing.T) {
|
||||||
|
good := publishBody(`{"name":"a","vers":"1.0.0"}`, []byte("c"))
|
||||||
|
for name, body := range map[string][]byte{
|
||||||
|
"empty": nil,
|
||||||
|
"short": good[:len(good)-1],
|
||||||
|
"trailing": append(append([]byte{}, good...), 'x'),
|
||||||
|
"bad json": publishBody(`{`, []byte("c")),
|
||||||
|
"no length": good[:6],
|
||||||
|
} {
|
||||||
|
if _, _, err := ParsePublish(body); err == nil {
|
||||||
|
t.Errorf("%s: expected error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewIndexLineValidation(t *testing.T) {
|
||||||
|
for _, m := range []PublishMeta{
|
||||||
|
{Name: "", Vers: "1.0.0"},
|
||||||
|
{Name: "1abc", Vers: "1.0.0"},
|
||||||
|
{Name: "../x", Vers: "1.0.0"},
|
||||||
|
{Name: "ok", Vers: "1.0.0/../../x"},
|
||||||
|
{Name: "ok", Vers: ""},
|
||||||
|
{Name: strings.Repeat("a", 65), Vers: "1.0.0"},
|
||||||
|
{Name: "ok", Vers: "1"},
|
||||||
|
{Name: "ok", Vers: "1.0"},
|
||||||
|
{Name: "ok", Vers: "1..2"},
|
||||||
|
{Name: "ok", Vers: "01.0.0"},
|
||||||
|
{Name: "ok", Vers: "1.0.0-"},
|
||||||
|
{Name: "ok", Vers: "1.0.0-01"},
|
||||||
|
{Name: "ok", Vers: "1.0.0+"},
|
||||||
|
{Name: "ok", Vers: "1.0.0+a..b"},
|
||||||
|
{Name: "ok", Vers: "v1.0.0"},
|
||||||
|
} {
|
||||||
|
if _, err := NewIndexLine(m, []byte("x")); err == nil {
|
||||||
|
t.Errorf("%q@%q accepted", m.Name, m.Vers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range []string{"0.0.0", "1.0.0-rc.1+build.5", "1.2.3-alpha.0.x-y", "10.20.30+20261017"} {
|
||||||
|
if _, err := NewIndexLine(PublishMeta{Name: "Foo_bar-2", Vers: v}, []byte("x")); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := NewIndexLine(PublishMeta{Name: "ok", Vers: "1.0.0"}, nil); err == nil {
|
||||||
|
t.Error("empty .crate accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexPath(t *testing.T) {
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"a": "1/a", "ab": "2/ab", "abc": "3/a/abc", "Serde": "se/rd/serde", "unkin-hello": "un/ki/unkin-hello",
|
||||||
|
} {
|
||||||
|
if got := IndexPath(name); got != want {
|
||||||
|
t.Errorf("IndexPath(%q) = %q, want %q", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeIndex struct {
|
||||||
|
entries []provider.CargoIndexEntry
|
||||||
|
asked string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIndex) ListCargoIndexEntries(_ context.Context, _, name string) ([]provider.CargoIndexEntry, error) {
|
||||||
|
f.asked = name
|
||||||
|
return f.entries, nil
|
||||||
|
}
|
||||||
|
func (f *fakeIndex) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeIndex) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
||||||
|
|
||||||
|
func TestServeLocalIndex(t *testing.T) {
|
||||||
|
line, _ := NewIndexLine(PublishMeta{Name: "unkin-hello", Vers: "0.1.0"}, []byte("x"))
|
||||||
|
raw, _ := json.Marshal(line)
|
||||||
|
files := &fakeIndex{entries: []provider.CargoIndexEntry{
|
||||||
|
{Name: "unkin-hello", Version: "0.1.0", Line: string(raw), Yanked: true},
|
||||||
|
{Name: "unkin-hello", Version: "0.2.0", Line: strings.Replace(string(raw), "0.1.0", "0.2.0", 1)},
|
||||||
|
}}
|
||||||
|
p := &Provider{}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if !p.ServeLocalIndex(rec, httptest.NewRequest("GET", "http://aa.example/x", nil), files, "crates-local", "un/ki/unkin-hello") {
|
||||||
|
t.Fatal("index path not served")
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(rec.Body.String()), "\n")
|
||||||
|
if len(lines) != 2 || !strings.Contains(lines[0], `"yanked":true`) || !strings.Contains(lines[1], `"yanked":false`) {
|
||||||
|
t.Errorf("index body = %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
if files.asked != "unkin-hello" {
|
||||||
|
t.Errorf("looked up %q", files.asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "http://aa.example/config.json", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
p.ServeLocalIndex(rec, req, files, "crates-local", "config.json")
|
||||||
|
var cfg map[string]string
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &cfg)
|
||||||
|
if cfg["api"] != "https://aa.example/api/v1/local/crates-local" ||
|
||||||
|
cfg["dl"] != "https://aa.example/api/v1/local/crates-local/crates/{crate}/{crate}-{version}.crate" {
|
||||||
|
t.Errorf("config = %v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{"crates/unkin-hello/unkin-hello-0.1.0.crate", "xx/ki/unkin-hello", ""} {
|
||||||
|
if p.ServeLocalIndex(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil), files, "r", path) {
|
||||||
|
t.Errorf("%q should fall through", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.entries = nil
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
p.ServeLocalIndex(rec, httptest.NewRequest("GET", "/", nil), files, "r", "3/a/abc")
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Errorf("unknown crate code = %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCratePathMatchesDL(t *testing.T) {
|
||||||
|
dl := LocalConfig("http://h/api/v1/local/r")["dl"]
|
||||||
|
want := strings.NewReplacer("{crate}", "unkin-hello", "{version}", "0.1.0").Replace(dl)
|
||||||
|
if got := "http://h/api/v1/local/r/" + CratePath("unkin-hello", "0.1.0"); got != want {
|
||||||
|
t.Errorf("CratePath = %q, dl expands to %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// CargoIndexEntry is one published crate version in a local cargo registry.
|
||||||
|
// Line is the sparse-index JSON line; Yanked is applied when it is served.
|
||||||
|
type CargoIndexEntry struct {
|
||||||
|
RepoName string
|
||||||
|
FilePath string
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
Line string
|
||||||
|
Yanked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CargoIndexReader lists every version of one crate (matched case-insensitively).
|
||||||
|
// *database.DB satisfies it and CargoIndexDeleter.
|
||||||
|
type CargoIndexReader interface {
|
||||||
|
ListCargoIndexEntries(ctx context.Context, repoName, name string) ([]CargoIndexEntry, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CargoIndexDeleter interface {
|
||||||
|
DeleteCargoIndexEntry(ctx context.Context, repoName, filePath string) error
|
||||||
|
}
|
||||||
@@ -431,7 +431,7 @@ func (p *GitHubProvider) fetchReleases(ctx context.Context, remote models.Remote
|
|||||||
return nil, "", false, err
|
return nil, "", false, err
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, "", false, fmt.Errorf("github releases API %s: status %d", u, resp.StatusCode)
|
return nil, "", false, provider.NewUpstreamStatusError("github releases API "+u, resp)
|
||||||
}
|
}
|
||||||
if page == 1 {
|
if page == 1 {
|
||||||
newEtag = respEtag
|
newEtag = respEtag
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type SyncStore interface {
|
|||||||
provider.RemoteMetadataStore
|
provider.RemoteMetadataStore
|
||||||
ListGitHubDebRemotes(ctx context.Context) ([]models.Remote, error)
|
ListGitHubDebRemotes(ctx context.Context) ([]models.Remote, error)
|
||||||
ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
||||||
ReleaseGitHubDebSyncLease(ctx context.Context, remoteName, owner, etag string, syncedAt time.Time) error
|
ReleaseGitHubDebSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncConfig tunes the shared syncer. Zero values fall back to safe defaults.
|
// SyncConfig tunes the shared syncer. Zero values fall back to safe defaults.
|
||||||
@@ -189,10 +189,11 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
freshness := time.Duration(job.remote.MutableTTL) * time.Second
|
ttl := time.Duration(job.remote.MutableTTL) * time.Second
|
||||||
if freshness <= 0 {
|
if ttl <= 0 {
|
||||||
freshness = defaultSyncFreshness
|
ttl = defaultSyncFreshness
|
||||||
}
|
}
|
||||||
|
freshness := ttl
|
||||||
if job.prime {
|
if job.prime {
|
||||||
freshness = 0
|
freshness = 0
|
||||||
}
|
}
|
||||||
@@ -210,16 +211,13 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
newEtag, changed, scanErr := s.prov.scanWithState(scanCtx, job.remote, s.store, etag)
|
newEtag, changed, scanErr := s.prov.scanWithState(scanCtx, job.remote, s.store, etag)
|
||||||
releaseEtag := etag
|
if scanErr != nil {
|
||||||
if scanErr == nil {
|
|
||||||
releaseEtag = newEtag
|
|
||||||
} else {
|
|
||||||
slog.Error("github_deb syncer: scan failed", "remote", job.remote.Name, "error", scanErr)
|
slog.Error("github_deb syncer: scan failed", "remote", job.remote.Name, "error", scanErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
relCtx, relCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
relCtx, relCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||||
defer relCancel()
|
defer relCancel()
|
||||||
if err := s.store.ReleaseGitHubDebSyncLease(relCtx, job.remote.Name, s.owner, releaseEtag, time.Now()); err != nil {
|
if err := s.store.ReleaseGitHubDebSyncLease(relCtx, job.remote.Name, s.owner, provider.NewSyncResult(newEtag, scanErr, ttl)); err != nil {
|
||||||
slog.Warn("github_deb syncer: release lease", "remote", job.remote.Name, "error", err)
|
slog.Warn("github_deb syncer: release lease", "remote", job.remote.Name, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type fakeSyncStore struct {
|
|||||||
leaseExp map[string]time.Time
|
leaseExp map[string]time.Time
|
||||||
lastSynced map[string]time.Time
|
lastSynced map[string]time.Time
|
||||||
etags map[string]string
|
etags map[string]string
|
||||||
|
retryAt map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeSyncStore() *fakeSyncStore {
|
func newFakeSyncStore() *fakeSyncStore {
|
||||||
@@ -36,6 +37,7 @@ func newFakeSyncStore() *fakeSyncStore {
|
|||||||
leaseExp: map[string]time.Time{},
|
leaseExp: map[string]time.Time{},
|
||||||
lastSynced: map[string]time.Time{},
|
lastSynced: map[string]time.Time{},
|
||||||
etags: map[string]string{},
|
etags: map[string]string{},
|
||||||
|
retryAt: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,9 @@ func (f *fakeSyncStore) ClaimGitHubDebSyncLease(_ context.Context, name, owner s
|
|||||||
ls, hasLS := f.lastSynced[name]
|
ls, hasLS := f.lastSynced[name]
|
||||||
exp, hasExp := f.leaseExp[name]
|
exp, hasExp := f.leaseExp[name]
|
||||||
freshOK := !hasLS || now.Sub(ls) >= freshness
|
freshOK := !hasLS || now.Sub(ls) >= freshness
|
||||||
|
if ra, pending := f.retryAt[name]; pending {
|
||||||
|
freshOK = !now.Before(ra)
|
||||||
|
}
|
||||||
leaseOK := !hasExp || exp.Before(now)
|
leaseOK := !hasExp || exp.Before(now)
|
||||||
if freshOK && leaseOK {
|
if freshOK && leaseOK {
|
||||||
f.leaseOwner[name] = owner
|
f.leaseOwner[name] = owner
|
||||||
@@ -61,14 +66,19 @@ func (f *fakeSyncStore) ClaimGitHubDebSyncLease(_ context.Context, name, owner s
|
|||||||
return false, "", nil
|
return false, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSyncStore) ReleaseGitHubDebSyncLease(_ context.Context, name, owner, etag string, syncedAt time.Time) error {
|
func (f *fakeSyncStore) ReleaseGitHubDebSyncLease(_ context.Context, name, owner string, res provider.SyncResult) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
if f.leaseOwner[name] != owner {
|
if f.leaseOwner[name] != owner {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
f.lastSynced[name] = syncedAt
|
if res.Failed {
|
||||||
f.etags[name] = etag
|
f.retryAt[name] = time.Now().Add(res.Backoff)
|
||||||
|
} else {
|
||||||
|
f.lastSynced[name] = time.Now()
|
||||||
|
f.etags[name] = res.Etag
|
||||||
|
delete(f.retryAt, name)
|
||||||
|
}
|
||||||
delete(f.leaseOwner, name)
|
delete(f.leaseOwner, name)
|
||||||
delete(f.leaseExp, name)
|
delete(f.leaseExp, name)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Package nuget proxies a NuGet v3 feed: the service index, registration pages
|
||||||
|
// and flat container indexes are mutable, .nupkg/.nuspec downloads immutable.
|
||||||
|
package nuget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
provider.Register(&Provider{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceIndex is the path clients use as their source URL; it maps to base_url.
|
||||||
|
const serviceIndex = "index.json"
|
||||||
|
|
||||||
|
type Provider struct{}
|
||||||
|
|
||||||
|
func (p *Provider) Type() models.PackageType { return models.PackageNuGet }
|
||||||
|
|
||||||
|
func (p *Provider) Classify(path string) provider.Mutability {
|
||||||
|
if isPackage(path) {
|
||||||
|
return provider.Immutable
|
||||||
|
}
|
||||||
|
return provider.Mutable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) ContentType(path string) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(path, ".nupkg"):
|
||||||
|
return "application/octet-stream"
|
||||||
|
case strings.HasSuffix(path, ".nuspec"):
|
||||||
|
return "application/xml"
|
||||||
|
}
|
||||||
|
return "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpstreamURL maps index.json to base_url (the upstream service index) and any
|
||||||
|
// other path to the same path on the base_url's origin, where nuget.org serves
|
||||||
|
// v3-flatcontainer/ and v3/registration*/.
|
||||||
|
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||||
|
path = strings.TrimLeft(path, "/")
|
||||||
|
if path == serviceIndex {
|
||||||
|
return remote.BaseURL
|
||||||
|
}
|
||||||
|
return origin(remote.BaseURL) + "/" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewriteServed points upstream-origin URLs in cached JSON back at this remote.
|
||||||
|
// It runs per request because the URLs must name the requesting host.
|
||||||
|
func (p *Provider) RewriteServed(body []byte, remote models.Remote, proxyBaseURL string) []byte {
|
||||||
|
from := origin(remote.BaseURL)
|
||||||
|
if from == "" || !json.Valid(body) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
to := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name
|
||||||
|
return []byte(strings.ReplaceAll(string(body), from, to))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
|
||||||
|
return auth.BasicHeaders(remote), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func origin(baseURL string) string {
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPackage(path string) bool {
|
||||||
|
return strings.HasSuffix(path, ".nupkg") || strings.HasSuffix(path, ".nuspec")
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package nuget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ provider.ServeRewriter = (*Provider)(nil)
|
||||||
|
|
||||||
|
var nugetOrg = models.Remote{Name: "nuget-org", BaseURL: "https://api.nuget.org/v3/index.json"}
|
||||||
|
|
||||||
|
func TestClassify(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want provider.Mutability
|
||||||
|
}{
|
||||||
|
{"v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg", provider.Immutable},
|
||||||
|
{"v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.nuspec", provider.Immutable},
|
||||||
|
{"v3-flatcontainer/newtonsoft.json/index.json", provider.Mutable},
|
||||||
|
{"v3/registration5-gz-semver2/newtonsoft.json/index.json", provider.Mutable},
|
||||||
|
{"index.json", provider.Mutable},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := p.Classify(tt.path); got != tt.want {
|
||||||
|
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamURL(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
tests := []struct {
|
||||||
|
path, want string
|
||||||
|
}{
|
||||||
|
{"index.json", "https://api.nuget.org/v3/index.json"},
|
||||||
|
{"/index.json", "https://api.nuget.org/v3/index.json"},
|
||||||
|
{"v3-flatcontainer/serilog/index.json", "https://api.nuget.org/v3-flatcontainer/serilog/index.json"},
|
||||||
|
{"v3/registration5-gz-semver2/serilog/index.json", "https://api.nuget.org/v3/registration5-gz-semver2/serilog/index.json"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := p.UpstreamURL(nugetOrg, tt.path); got != tt.want {
|
||||||
|
t.Errorf("UpstreamURL(%q) = %q, want %q", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteServed(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
body := `{"resources":[` +
|
||||||
|
`{"@id":"https://api.nuget.org/v3-flatcontainer/","@type":"PackageBaseAddress/3.0.0"},` +
|
||||||
|
`{"@id":"https://azuresearch-usnc.nuget.org/query","@type":"SearchQueryService"}]}`
|
||||||
|
want := `{"resources":[` +
|
||||||
|
`{"@id":"http://aa.example:8000/api/v1/remote/nuget-org/v3-flatcontainer/","@type":"PackageBaseAddress/3.0.0"},` +
|
||||||
|
`{"@id":"https://azuresearch-usnc.nuget.org/query","@type":"SearchQueryService"}]}`
|
||||||
|
if got := string(p.RewriteServed([]byte(body), nugetOrg, "http://aa.example:8000/")); got != want {
|
||||||
|
t.Errorf("RewriteServed =\n%s\nwant\n%s", got, want)
|
||||||
|
}
|
||||||
|
if got := string(p.RewriteServed([]byte(body), models.Remote{Name: "x"}, "http://aa.example")); got != body {
|
||||||
|
t.Error("remote without a parseable base_url must pass the body through")
|
||||||
|
}
|
||||||
|
cert := "\x30\x82https://api.nuget.org"
|
||||||
|
if got := string(p.RewriteServed([]byte(cert), nugetOrg, "http://aa.example")); got != cert {
|
||||||
|
t.Error("non-JSON bodies must pass through untouched")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,13 @@ type RemoteServer interface {
|
|||||||
ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, store RemoteMetadataStore) bool
|
ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, store RemoteMetadataStore) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServeRewriter rewrites a cached mutable remote response per request, for
|
||||||
|
// indexes whose URLs must name the requesting host and so cannot be rewritten
|
||||||
|
// before caching.
|
||||||
|
type ServeRewriter interface {
|
||||||
|
RewriteServed(body []byte, remote models.Remote, proxyBaseURL string) []byte
|
||||||
|
}
|
||||||
|
|
||||||
// RemoteMetadataStore is the persistence surface a RemoteServer needs to cache
|
// RemoteMetadataStore is the persistence surface a RemoteServer needs to cache
|
||||||
// and read the metadata it derives per upstream asset. *database.DB satisfies it.
|
// and read the metadata it derives per upstream asset. *database.DB satisfies it.
|
||||||
type RemoteMetadataStore interface {
|
type RemoteMetadataStore interface {
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ func (p *GitHubProvider) fetchReleases(ctx context.Context, remote models.Remote
|
|||||||
return nil, "", false, err
|
return nil, "", false, err
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, "", false, fmt.Errorf("github releases API %s: status %d", u, resp.StatusCode)
|
return nil, "", false, provider.NewUpstreamStatusError("github releases API "+u, resp)
|
||||||
}
|
}
|
||||||
if page == 1 {
|
if page == 1 {
|
||||||
newEtag = respEtag
|
newEtag = respEtag
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ type githubFixture struct {
|
|||||||
notModHit int // releases-list requests answered 304
|
notModHit int // releases-list requests answered 304
|
||||||
releaseAuth string // Authorization header seen on the last releases request
|
releaseAuth string // Authorization header seen on the last releases request
|
||||||
assetAuth string // Authorization header seen on the last asset request
|
assetAuth string // Authorization header seen on the last asset request
|
||||||
|
failStatus int // when set, the releases list answers this status (rate-limit style)
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +101,14 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.releasesHit++
|
f.releasesHit++
|
||||||
f.releaseAuth = r.Header.Get("Authorization")
|
f.releaseAuth = r.Header.Get("Authorization")
|
||||||
|
if f.failStatus != 0 {
|
||||||
|
status := f.failStatus
|
||||||
|
f.mu.Unlock()
|
||||||
|
w.Header().Set("X-RateLimit-Remaining", "0")
|
||||||
|
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(30*time.Second).Unix(), 10))
|
||||||
|
http.Error(w, "API rate limit exceeded", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
etag := f.etag
|
etag := f.etag
|
||||||
if etag != "" && r.Header.Get("If-None-Match") == etag {
|
if etag != "" && r.Header.Get("If-None-Match") == etag {
|
||||||
f.notModHit++
|
f.notModHit++
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ type SyncStore interface {
|
|||||||
provider.RemoteMetadataStore
|
provider.RemoteMetadataStore
|
||||||
ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error)
|
ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error)
|
||||||
ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
||||||
ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner, etag string, syncedAt time.Time) error
|
ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncConfig tunes the shared syncer. Zero values fall back to safe defaults.
|
// SyncConfig tunes the shared syncer. Zero values fall back to safe defaults.
|
||||||
@@ -205,10 +205,11 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
freshness := time.Duration(job.remote.MutableTTL) * time.Second
|
ttl := time.Duration(job.remote.MutableTTL) * time.Second
|
||||||
if freshness <= 0 {
|
if ttl <= 0 {
|
||||||
freshness = defaultSyncFreshness
|
ttl = defaultSyncFreshness
|
||||||
}
|
}
|
||||||
|
freshness := ttl
|
||||||
if job.prime {
|
if job.prime {
|
||||||
freshness = 0 // prime ignores the recency gate but still respects a live lease
|
freshness = 0 // prime ignores the recency gate but still respects a live lease
|
||||||
}
|
}
|
||||||
@@ -226,18 +227,15 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
newEtag, changed, scanErr := s.prov.scanWithState(scanCtx, job.remote, s.store, etag)
|
newEtag, changed, scanErr := s.prov.scanWithState(scanCtx, job.remote, s.store, etag)
|
||||||
releaseEtag := etag
|
if scanErr != nil {
|
||||||
if scanErr == nil {
|
|
||||||
releaseEtag = newEtag
|
|
||||||
} else {
|
|
||||||
slog.Error("github_rpm syncer: scan failed", "remote", job.remote.Name, "error", scanErr)
|
slog.Error("github_rpm syncer: scan failed", "remote", job.remote.Name, "error", scanErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release on a detached context so a clean shutdown mid-scan still frees the
|
// Release on a detached context so a clean shutdown mid-scan still frees the
|
||||||
// lease and advances last_synced_at (otherwise it simply expires).
|
// lease and records the outcome (otherwise the lease simply expires).
|
||||||
relCtx, relCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
relCtx, relCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||||
defer relCancel()
|
defer relCancel()
|
||||||
if err := s.store.ReleaseGitHubSyncLease(relCtx, job.remote.Name, s.owner, releaseEtag, time.Now()); err != nil {
|
if err := s.store.ReleaseGitHubSyncLease(relCtx, job.remote.Name, s.owner, provider.NewSyncResult(newEtag, scanErr, ttl)); err != nil {
|
||||||
slog.Warn("github_rpm syncer: release lease", "remote", job.remote.Name, "error", err)
|
slog.Warn("github_rpm syncer: release lease", "remote", job.remote.Name, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type fakeSyncStore struct {
|
|||||||
leaseExp map[string]time.Time
|
leaseExp map[string]time.Time
|
||||||
lastSynced map[string]time.Time
|
lastSynced map[string]time.Time
|
||||||
etags map[string]string
|
etags map[string]string
|
||||||
|
retryAt map[string]time.Time
|
||||||
|
lastResult provider.SyncResult
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeSyncStore() *fakeSyncStore {
|
func newFakeSyncStore() *fakeSyncStore {
|
||||||
@@ -36,6 +38,7 @@ func newFakeSyncStore() *fakeSyncStore {
|
|||||||
leaseExp: map[string]time.Time{},
|
leaseExp: map[string]time.Time{},
|
||||||
lastSynced: map[string]time.Time{},
|
lastSynced: map[string]time.Time{},
|
||||||
etags: map[string]string{},
|
etags: map[string]string{},
|
||||||
|
retryAt: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +55,9 @@ func (f *fakeSyncStore) ClaimGitHubSyncLease(_ context.Context, name, owner stri
|
|||||||
ls, hasLS := f.lastSynced[name]
|
ls, hasLS := f.lastSynced[name]
|
||||||
exp, hasExp := f.leaseExp[name]
|
exp, hasExp := f.leaseExp[name]
|
||||||
freshOK := !hasLS || now.Sub(ls) >= freshness
|
freshOK := !hasLS || now.Sub(ls) >= freshness
|
||||||
|
if ra, pending := f.retryAt[name]; pending {
|
||||||
|
freshOK = !now.Before(ra)
|
||||||
|
}
|
||||||
leaseOK := !hasExp || exp.Before(now)
|
leaseOK := !hasExp || exp.Before(now)
|
||||||
if freshOK && leaseOK {
|
if freshOK && leaseOK {
|
||||||
f.leaseOwner[name] = owner
|
f.leaseOwner[name] = owner
|
||||||
@@ -61,14 +67,20 @@ func (f *fakeSyncStore) ClaimGitHubSyncLease(_ context.Context, name, owner stri
|
|||||||
return false, "", nil
|
return false, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSyncStore) ReleaseGitHubSyncLease(_ context.Context, name, owner, etag string, syncedAt time.Time) error {
|
func (f *fakeSyncStore) ReleaseGitHubSyncLease(_ context.Context, name, owner string, res provider.SyncResult) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
if f.leaseOwner[name] != owner {
|
if f.leaseOwner[name] != owner {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
f.lastSynced[name] = syncedAt
|
f.lastResult = res
|
||||||
f.etags[name] = etag
|
if res.Failed {
|
||||||
|
f.retryAt[name] = time.Now().Add(res.Backoff)
|
||||||
|
} else {
|
||||||
|
f.lastSynced[name] = time.Now()
|
||||||
|
f.etags[name] = res.Etag
|
||||||
|
delete(f.retryAt, name)
|
||||||
|
}
|
||||||
delete(f.leaseOwner, name)
|
delete(f.leaseOwner, name)
|
||||||
delete(f.leaseExp, name)
|
delete(f.leaseExp, name)
|
||||||
return nil
|
return nil
|
||||||
@@ -310,3 +322,80 @@ func TestSyncerPrimeBypassesRecencyPeriodicDoesNot(t *testing.T) {
|
|||||||
t.Fatalf("periodic scan ran inside recency window: %d -> %d releases calls", releasesAfterPrime, fx.releasesHit)
|
t.Fatalf("periodic scan ran inside recency window: %d -> %d releases calls", releasesAfterPrime, fx.releasesHit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A rate-limited scan is not recorded as a sync: the last good repodata keeps
|
||||||
|
// serving, the retry honours the upstream reset hint and backoff, and once the
|
||||||
|
// upstream recovers the next poll after the backoff re-syncs without waiting
|
||||||
|
// out mutable_ttl.
|
||||||
|
func TestSyncerFailedScanRetriesAfterBackoff(t *testing.T) {
|
||||||
|
fx := newGitHubFixture(t, true)
|
||||||
|
fx.etag = `"v1"`
|
||||||
|
store := newFakeSyncStore()
|
||||||
|
p := newTestProvider()
|
||||||
|
s := newSyncer(store, p, testSyncConfig())
|
||||||
|
remote := fx.remote()
|
||||||
|
bg := context.Background()
|
||||||
|
|
||||||
|
s.process(bg, syncJob{remote: remote, prime: true})
|
||||||
|
if rows, _ := store.ListRPMMetadataEntries(bg, remote.Name); len(rows) != 1 {
|
||||||
|
t.Fatalf("prime did not derive: %d rows", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutable_ttl elapses while GitHub is rate-limiting.
|
||||||
|
fx.mu.Lock()
|
||||||
|
fx.failStatus = http.StatusForbidden
|
||||||
|
fx.etag = `"v2"`
|
||||||
|
fx.mu.Unlock()
|
||||||
|
store.mu.Lock()
|
||||||
|
store.lastSynced[remote.Name] = time.Now().Add(-2 * time.Hour)
|
||||||
|
store.mu.Unlock()
|
||||||
|
|
||||||
|
s.process(bg, syncJob{remote: remote})
|
||||||
|
res := store.lastResult
|
||||||
|
if !res.Failed {
|
||||||
|
t.Fatal("403 scan was recorded as a successful sync")
|
||||||
|
}
|
||||||
|
if until := time.Until(res.RetryAt); until < 20*time.Second || until > 40*time.Second {
|
||||||
|
t.Fatalf("retry hint from X-RateLimit-Reset not carried: retry in %v", until)
|
||||||
|
}
|
||||||
|
if res.Backoff != time.Minute || res.MaxBackoff >= time.Duration(remote.MutableTTL)*time.Second {
|
||||||
|
t.Fatalf("backoff %v cap %v, want 1m first retry capped below mutable_ttl", res.Backoff, res.MaxBackoff)
|
||||||
|
}
|
||||||
|
if store.etags[remote.Name] != `"v1"` {
|
||||||
|
t.Fatalf("failed scan replaced the etag: %q", store.etags[remote.Name])
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/remote/acme-rpm/repodata/repomd.xml", nil)
|
||||||
|
p.ServeRemote(rec, req, remote, "repodata/repomd.xml", "https://x", store)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("last good repodata not served during failure: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rows, _ := store.ListRPMMetadataEntries(bg, remote.Name); len(rows) != 1 {
|
||||||
|
t.Fatalf("failed scan dropped cached metadata: %d rows", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside the backoff nothing is retried.
|
||||||
|
hits := fx.releasesHit
|
||||||
|
s.process(bg, syncJob{remote: remote})
|
||||||
|
if fx.releasesHit != hits {
|
||||||
|
t.Fatal("scan retried inside the backoff window")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream recovers and the backoff elapses: the next poll re-syncs.
|
||||||
|
fx.mu.Lock()
|
||||||
|
fx.failStatus = 0
|
||||||
|
fx.rpmBytes["other-9-9.aarch64.rpm"] = testsupport.MinimalRPM("other", "9", "9", "aarch64")
|
||||||
|
fx.mu.Unlock()
|
||||||
|
store.mu.Lock()
|
||||||
|
store.retryAt[remote.Name] = time.Now().Add(-time.Second)
|
||||||
|
store.mu.Unlock()
|
||||||
|
|
||||||
|
s.process(bg, syncJob{remote: remote})
|
||||||
|
if store.lastResult.Failed || store.etags[remote.Name] != `"v2"` {
|
||||||
|
t.Fatalf("recovery scan not recorded: failed=%v etag=%q", store.lastResult.Failed, store.etags[remote.Name])
|
||||||
|
}
|
||||||
|
if rows, _ := store.ListRPMMetadataEntries(bg, remote.Name); len(rows) != 2 {
|
||||||
|
t.Fatalf("recovery did not refresh metadata: %d rows", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
syncRetryBase = time.Minute
|
||||||
|
syncRetryMax = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpstreamStatusError is a non-success upstream response. RetryAt is the
|
||||||
|
// upstream's own retry hint (Retry-After, or X-RateLimit-Reset once the quota
|
||||||
|
// is exhausted); zero when it gave none.
|
||||||
|
type UpstreamStatusError struct {
|
||||||
|
URL string
|
||||||
|
Status int
|
||||||
|
RetryAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UpstreamStatusError) Error() string {
|
||||||
|
return fmt.Sprintf("%s: status %d", e.URL, e.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUpstreamStatusError wraps a non-success response, capturing its retry hint.
|
||||||
|
func NewUpstreamStatusError(url string, resp *http.Response) *UpstreamStatusError {
|
||||||
|
e := &UpstreamStatusError{URL: url, Status: resp.StatusCode}
|
||||||
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||||
|
if secs, err := strconv.Atoi(ra); err == nil {
|
||||||
|
e.RetryAt = time.Now().Add(time.Duration(secs) * time.Second)
|
||||||
|
} else if t, err := http.ParseTime(ra); err == nil {
|
||||||
|
e.RetryAt = t
|
||||||
|
}
|
||||||
|
} else if resp.Header.Get("X-RateLimit-Remaining") == "0" {
|
||||||
|
if reset, err := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64); err == nil {
|
||||||
|
e.RetryAt = time.Unix(reset, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncResult is a background scan's outcome, recorded when its sync lease is
|
||||||
|
// released. A failed scan keeps the prior sync time and ETag and schedules a
|
||||||
|
// retry after Backoff, doubled per consecutive failure up to MaxBackoff, and
|
||||||
|
// never earlier than RetryAt.
|
||||||
|
type SyncResult struct {
|
||||||
|
Etag string
|
||||||
|
Failed bool
|
||||||
|
RetryAt time.Time
|
||||||
|
Backoff time.Duration
|
||||||
|
MaxBackoff time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSyncResult builds the result for a scan against a remote with the given
|
||||||
|
// mutable_ttl. The retry cap stays well below ttl, and an upstream hint is
|
||||||
|
// clamped to ttl so a bogus reset can never stall the remote longer than a
|
||||||
|
// normal sync interval would.
|
||||||
|
func NewSyncResult(etag string, scanErr error, ttl time.Duration) SyncResult {
|
||||||
|
if scanErr == nil {
|
||||||
|
return SyncResult{Etag: etag}
|
||||||
|
}
|
||||||
|
res := SyncResult{Failed: true, Backoff: syncRetryBase, MaxBackoff: min(syncRetryMax, max(syncRetryBase, ttl/4))}
|
||||||
|
var se *UpstreamStatusError
|
||||||
|
if errors.As(scanErr, &se) && !se.RetryAt.IsZero() {
|
||||||
|
res.RetryAt = se.RetryAt
|
||||||
|
if limit := time.Now().Add(ttl); res.RetryAt.After(limit) {
|
||||||
|
res.RetryAt = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewUpstreamStatusErrorRetryHint(t *testing.T) {
|
||||||
|
reset := time.Now().Add(15 * time.Minute).Truncate(time.Second)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
header map[string]string
|
||||||
|
want time.Duration // 0 = no hint
|
||||||
|
}{
|
||||||
|
{"retry-after seconds", map[string]string{"Retry-After": "90"}, 90 * time.Second},
|
||||||
|
{"retry-after date", map[string]string{"Retry-After": reset.UTC().Format(http.TimeFormat)}, 15 * time.Minute},
|
||||||
|
{"quota exhausted", map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": strconv.FormatInt(reset.Unix(), 10)}, 15 * time.Minute},
|
||||||
|
{"quota left is not a hint", map[string]string{"X-RateLimit-Remaining": "12", "X-RateLimit-Reset": strconv.FormatInt(reset.Unix(), 10)}, 0},
|
||||||
|
{"no headers", nil, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
resp := &http.Response{StatusCode: http.StatusForbidden, Header: http.Header{}}
|
||||||
|
for k, v := range c.header {
|
||||||
|
resp.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
e := NewUpstreamStatusError("u", resp)
|
||||||
|
if c.want == 0 {
|
||||||
|
if !e.RetryAt.IsZero() {
|
||||||
|
t.Fatalf("unexpected hint %v", e.RetryAt)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d := time.Until(e.RetryAt) - c.want; d < -2*time.Second || d > 2*time.Second {
|
||||||
|
t.Fatalf("retry in %v, want ~%v", time.Until(e.RetryAt), c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSyncResult(t *testing.T) {
|
||||||
|
if r := NewSyncResult(`"e"`, nil, time.Hour); r.Failed || r.Etag != `"e"` {
|
||||||
|
t.Fatalf("success result = %+v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewSyncResult(`"e"`, errors.New("boom"), time.Hour)
|
||||||
|
if !r.Failed || r.Backoff != time.Minute || r.MaxBackoff != 10*time.Minute || !r.RetryAt.IsZero() {
|
||||||
|
t.Fatalf("plain failure = %+v, want 1m backoff capped at 10m, no hint", r)
|
||||||
|
}
|
||||||
|
if r := NewSyncResult("", errors.New("boom"), 5*time.Minute); r.MaxBackoff != time.Minute+15*time.Second {
|
||||||
|
t.Fatalf("short ttl cap = %v, want ttl/4", r.MaxBackoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
far := &UpstreamStatusError{Status: 403, RetryAt: time.Now().Add(3 * time.Hour)}
|
||||||
|
r = NewSyncResult("", fmt.Errorf("scan: %w", far), time.Hour)
|
||||||
|
if until := time.Until(r.RetryAt); until > time.Hour || until < 59*time.Minute {
|
||||||
|
t.Fatalf("wrapped hint not clamped to ttl: retry in %v", until)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ func cors(next http.Handler) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServerOIDCWriteAuth(t *testing.T) {
|
||||||
|
requireStack(t)
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
defer iss.Close()
|
||||||
|
|
||||||
|
cfg := *testSrv.cfg
|
||||||
|
cfg.OIDCIssuer = iss.URL
|
||||||
|
cfg.OIDCAudiences = []string{"artifactapi"}
|
||||||
|
s := *testSrv
|
||||||
|
s.cfg = &cfg
|
||||||
|
ts := httptest.NewServer(s.routes())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
good := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||||
|
do := func(method, path, body, token string) int {
|
||||||
|
t.Helper()
|
||||||
|
rq, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
|
||||||
|
rq.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
rq.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(rq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
expect := func(want int, method, path, body, token string) {
|
||||||
|
t.Helper()
|
||||||
|
if got := do(method, path, body, token); got != want {
|
||||||
|
t.Errorf("%s %s: status %d, want %d", method, path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remote := `{"name":"srv-oidc","package_type":"generic","repo_type":"local"}`
|
||||||
|
virtual := `{"name":"srv-oidc-v","package_type":"generic","members":["srv-oidc"]}`
|
||||||
|
|
||||||
|
expect(401, "POST", "/api/v2/remotes", remote, "")
|
||||||
|
expect(401, "POST", "/api/v2/remotes", remote, iss.Token(nil, "other", "ci", time.Hour))
|
||||||
|
expect(201, "POST", "/api/v2/remotes", remote, good)
|
||||||
|
expect(401, "PUT", "/api/v2/remotes/srv-oidc", `{"package_type":"generic"}`, "")
|
||||||
|
expect(401, "DELETE", "/api/v2/remotes/srv-oidc", "", "")
|
||||||
|
expect(401, "POST", "/api/v2/virtuals", virtual, "")
|
||||||
|
expect(201, "POST", "/api/v2/virtuals", virtual, good)
|
||||||
|
expect(401, "PUT", "/api/v2/virtuals/srv-oidc-v", virtual, "")
|
||||||
|
expect(401, "DELETE", "/api/v2/virtuals/srv-oidc-v", "", "")
|
||||||
|
|
||||||
|
expect(200, "GET", "/api/v2/remotes", "", "")
|
||||||
|
expect(200, "GET", "/api/v2/remotes/srv-oidc", "", "")
|
||||||
|
expect(200, "GET", "/api/v2/virtuals/srv-oidc-v", "", "")
|
||||||
|
expect(201, "PUT", "/api/v2/remotes/srv-oidc/files/a.bin", "payload", "")
|
||||||
|
expect(200, "GET", "/api/v2/remotes/srv-oidc/files/a.bin", "", "")
|
||||||
|
if got := do("DELETE", "/api/v2/remotes/srv-oidc/files/a.bin", "", ""); got >= 400 {
|
||||||
|
t.Errorf("delete file: status %d", got)
|
||||||
|
}
|
||||||
|
if got := do("POST", "/api/v2/probe", `{}`, ""); got == 401 {
|
||||||
|
t.Error("probe gated")
|
||||||
|
}
|
||||||
|
if got := do("GET", "/v2/", "", ""); got == 401 {
|
||||||
|
t.Error("docker /v2 gated")
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(204, "DELETE", "/api/v2/virtuals/srv-oidc-v", "", good)
|
||||||
|
expect(204, "DELETE", "/api/v2/remotes/srv-oidc", "", good)
|
||||||
|
}
|
||||||
@@ -15,18 +15,21 @@ import (
|
|||||||
tfregistry "git.unkin.net/unkin/artifactapi/internal/api/terraform"
|
tfregistry "git.unkin.net/unkin/artifactapi/internal/api/terraform"
|
||||||
v1 "git.unkin.net/unkin/artifactapi/internal/api/v1"
|
v1 "git.unkin.net/unkin/artifactapi/internal/api/v1"
|
||||||
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
|
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/cache"
|
"git.unkin.net/unkin/artifactapi/internal/cache"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/config"
|
"git.unkin.net/unkin/artifactapi/internal/config"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/gc"
|
"git.unkin.net/unkin/artifactapi/internal/gc"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/githubauth"
|
"git.unkin.net/unkin/artifactapi/internal/githubauth"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider/alpine"
|
"git.unkin.net/unkin/artifactapi/internal/provider/alpine"
|
||||||
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/cargo"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider/deb"
|
"git.unkin.net/unkin/artifactapi/internal/provider/deb"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/docker"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/docker"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/generic"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/generic"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/helm"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/helm"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/npm"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/npm"
|
||||||
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/nuget"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/pypi"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/pypi"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
"git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
||||||
@@ -185,10 +188,11 @@ func (s *Server) routes() chi.Router {
|
|||||||
statsHandler := v2.NewStatsHandler(s.db)
|
statsHandler := v2.NewStatsHandler(s.db)
|
||||||
eventsHandler := v2.NewEventsHandler()
|
eventsHandler := v2.NewEventsHandler()
|
||||||
probeHandler := v2.NewProbeHandler(s.engine, s.db)
|
probeHandler := v2.NewProbeHandler(s.engine, s.db)
|
||||||
|
writeAuth := auth.NewBearerOIDC(s.cfg.OIDCIssuer, s.cfg.OIDCAudiences)
|
||||||
|
|
||||||
r.Route("/api/v2", func(r chi.Router) {
|
r.Route("/api/v2", func(r chi.Router) {
|
||||||
r.Mount("/remotes", remotesHandler.Routes())
|
r.Mount("/remotes", writeAuth.RequireWrites(remotesHandler.Routes()))
|
||||||
r.Mount("/virtuals", virtualsHandler.Routes())
|
r.Mount("/virtuals", writeAuth.RequireWrites(virtualsHandler.Routes()))
|
||||||
r.Mount("/health", healthHandler.Routes())
|
r.Mount("/health", healthHandler.Routes())
|
||||||
r.Mount("/stats", statsHandler.Routes())
|
r.Mount("/stats", statsHandler.Routes())
|
||||||
r.Mount("/events", eventsHandler.Routes())
|
r.Mount("/events", eventsHandler.Routes())
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package testsupport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-jose/go-jose/v4"
|
||||||
|
"github.com/go-jose/go-jose/v4/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OIDCIssuer is a minimal OIDC issuer serving discovery and JWKS, signing
|
||||||
|
// RS256 tokens with a generated key.
|
||||||
|
type OIDCIssuer struct {
|
||||||
|
*httptest.Server
|
||||||
|
key *rsa.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOIDCIssuer() *OIDCIssuer {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
iss := &OIDCIssuer{key: key}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"issuer": iss.URL,
|
||||||
|
"jwks_uri": iss.URL + "/keys",
|
||||||
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{
|
||||||
|
{Key: &key.PublicKey, KeyID: "k1", Algorithm: "RS256", Use: "sig"},
|
||||||
|
}})
|
||||||
|
})
|
||||||
|
iss.Server = httptest.NewServer(mux)
|
||||||
|
return iss
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token signs a token for aud and sub. Pass a nil key to use the issuer's own;
|
||||||
|
// expiry is relative to now.
|
||||||
|
func (o *OIDCIssuer) Token(key *rsa.PrivateKey, aud, sub string, expiry time.Duration) string {
|
||||||
|
now := time.Now()
|
||||||
|
return o.Sign(key, jwt.Claims{
|
||||||
|
Issuer: o.URL,
|
||||||
|
Subject: sub,
|
||||||
|
Audience: jwt.Audience{aud},
|
||||||
|
IssuedAt: jwt.NewNumericDate(now.Add(-time.Minute)),
|
||||||
|
Expiry: jwt.NewNumericDate(now.Add(expiry)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign signs arbitrary claims with RS256. Pass a nil key to use the issuer's own.
|
||||||
|
func (o *OIDCIssuer) Sign(key *rsa.PrivateKey, claims jwt.Claims) string {
|
||||||
|
if key == nil {
|
||||||
|
key = o.key
|
||||||
|
}
|
||||||
|
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key},
|
||||||
|
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "k1"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
tok, err := jwt.Signed(sig).Claims(claims).Serialize()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
+262
-1
@@ -2,27 +2,58 @@ package virtual
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
proxyEngine *proxy.Engine
|
proxyEngine *proxy.Engine
|
||||||
|
getRemote func(context.Context, string) (*models.Remote, error)
|
||||||
|
rpmMember func(context.Context, string) (*RPMMember, error)
|
||||||
|
|
||||||
|
mergeTTL time.Duration
|
||||||
|
|
||||||
|
sf singleflight.Group
|
||||||
|
mu sync.Mutex
|
||||||
|
rpm map[string]*rpmGen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rpmGen holds a virtual's current merge plus the previous one, so a client
|
||||||
|
// holding the previous repomd can still fetch its content-hashed data files.
|
||||||
|
type rpmGen struct {
|
||||||
|
cur, prev *RPMRepo
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const rpmMergeTTL = 60 * time.Second
|
||||||
|
|
||||||
func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
|
func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
|
||||||
return &Engine{db: db, proxyEngine: proxyEngine}
|
e := &Engine{db: db, proxyEngine: proxyEngine, getRemote: db.GetRemote, mergeTTL: rpmMergeTTL}
|
||||||
|
e.rpmMember = e.fetchRPMMember
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) {
|
func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) {
|
||||||
|
if virt.PackageType == models.PackageRPM {
|
||||||
|
return e.fetchRPM(ctx, virt, path)
|
||||||
|
}
|
||||||
|
|
||||||
merger, err := GetMerger(virt.PackageType)
|
merger, err := GetMerger(virt.PackageType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("unsupported virtual type %q: %w", virt.PackageType, err)
|
return nil, "", fmt.Errorf("unsupported virtual type %q: %w", virt.PackageType, err)
|
||||||
@@ -133,3 +164,233 @@ func (e *Engine) fetchLocalIndex(ctx context.Context, remote models.Remote, path
|
|||||||
|
|
||||||
return indexer.GenerateLocalIndex(ctx, e.db, remote.Name, path)
|
return indexer.GenerateLocalIndex(ctx, e.db, remote.Name, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
ErrBadPath = errors.New("invalid path")
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemberRedirect maps an rpm virtual package path (<member>/<href>, as written
|
||||||
|
// by MergeRPM) to the owning member's route. ok is false for any other path;
|
||||||
|
// ErrBadPath means the path is absolute or escapes the member.
|
||||||
|
func (e *Engine) MemberRedirect(ctx context.Context, virt models.Virtual, path, rawQuery, proxyBaseURL string) (string, bool, error) {
|
||||||
|
if virt.PackageType != models.PackageRPM || strings.HasPrefix(path, "repodata/") {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
name, rest, found := strings.Cut(path, "/")
|
||||||
|
if !found || !slices.Contains(virt.Members, name) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
segs, err := memberSegments(rest)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
remote, err := e.getRemote(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
loc := fmt.Sprintf("%s/api/v1/%s/%s/%s", strings.TrimRight(proxyBaseURL, "/"), remote.RepoType, url.PathEscape(name), strings.Join(segs, "/"))
|
||||||
|
if rawQuery != "" {
|
||||||
|
loc += "?" + rawQuery
|
||||||
|
}
|
||||||
|
return loc, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberSegments decodes rest and returns its path-escaped segments, rejecting
|
||||||
|
// absolute paths and any "." or ".." segment.
|
||||||
|
func memberSegments(rest string) ([]string, error) {
|
||||||
|
dec, err := url.PathUnescape(rest)
|
||||||
|
if err != nil || dec == "" || strings.HasPrefix(dec, "/") {
|
||||||
|
return nil, ErrBadPath
|
||||||
|
}
|
||||||
|
segs := strings.Split(dec, "/")
|
||||||
|
for i, s := range segs {
|
||||||
|
if s == "." || s == ".." || strings.Contains(s, "\\") {
|
||||||
|
return nil, ErrBadPath
|
||||||
|
}
|
||||||
|
segs[i] = url.PathEscape(s)
|
||||||
|
}
|
||||||
|
return segs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) fetchRPM(ctx context.Context, virt models.Virtual, path string) ([]byte, string, error) {
|
||||||
|
if !strings.HasPrefix(path, "repodata/") {
|
||||||
|
return nil, "", ErrNotFound
|
||||||
|
}
|
||||||
|
if path != "repodata/repomd.xml" {
|
||||||
|
if body, ok := e.cachedRPMFile(virt.Name, path); ok {
|
||||||
|
return body, "application/gzip", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := e.mergedRPM(ctx, virt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if path == "repodata/repomd.xml" {
|
||||||
|
return repo.Repomd, "application/xml", nil
|
||||||
|
}
|
||||||
|
if body, ok := repo.Files[path]; ok {
|
||||||
|
return body, "application/gzip", nil
|
||||||
|
}
|
||||||
|
return nil, "", ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergedRPM returns the virtual's merge, reusing it for mergeTTL and
|
||||||
|
// collapsing concurrent merges of the same virtual into one.
|
||||||
|
// ponytail: per-replica cache; behind a non-sticky LB a data request landing on
|
||||||
|
// another replica re-merges and 404s if a member changed in between. Move to the
|
||||||
|
// shared redis cache if that shows up.
|
||||||
|
func (e *Engine) mergedRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
g := e.rpm[virt.Name]
|
||||||
|
if g != nil && time.Since(g.at) < e.mergeTTL {
|
||||||
|
e.mu.Unlock()
|
||||||
|
return g.cur, nil
|
||||||
|
}
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
v, err, _ := e.sf.Do(virt.Name, func() (any, error) {
|
||||||
|
repo, err := e.mergeRPM(context.WithoutCancel(ctx), virt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.rpm == nil {
|
||||||
|
e.rpm = map[string]*rpmGen{}
|
||||||
|
}
|
||||||
|
g := e.rpm[virt.Name]
|
||||||
|
switch {
|
||||||
|
case g == nil:
|
||||||
|
e.rpm[virt.Name] = &rpmGen{cur: repo, at: time.Now()}
|
||||||
|
case string(g.cur.Repomd) == string(repo.Repomd):
|
||||||
|
g.at = time.Now()
|
||||||
|
repo = g.cur
|
||||||
|
default:
|
||||||
|
g.prev, g.cur, g.at = g.cur, repo, time.Now()
|
||||||
|
}
|
||||||
|
return repo, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return v.(*RPMRepo), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) {
|
||||||
|
members := make([]RPMMember, len(virt.Members))
|
||||||
|
errs := make([]error, len(virt.Members))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, name := range virt.Members {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
m, err := e.rpmMember(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
errs[i] = fmt.Errorf("member %q: %w", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
members[i] = *m
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
// %v, not %w: a member failure is a 502 whatever its cause, never a 404.
|
||||||
|
if err := errors.Join(errs...); err != nil {
|
||||||
|
return nil, fmt.Errorf("virtual %q: %v", virt.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := MergeRPM(members)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("merge rpm repodata: %w", err)
|
||||||
|
}
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) cachedRPMFile(virt, path string) ([]byte, bool) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
g := e.rpm[virt]
|
||||||
|
if g == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, r := range []*RPMRepo{g.cur, g.prev} {
|
||||||
|
if r == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if body, ok := r.Files[path]; ok {
|
||||||
|
return body, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) fetchRPMMember(ctx context.Context, name string) (*RPMMember, error) {
|
||||||
|
remote, err := e.getRemote(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("remote %q: %w", name, err)
|
||||||
|
}
|
||||||
|
repomd, err := e.fetchMemberPath(ctx, *remote, "repodata/repomd.xml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
locs, ts, err := parseRepomd(repomd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := &RPMMember{RemoteName: name, Bases: remote.UpstreamPool(), Timestamp: ts, Data: map[string][]byte{}}
|
||||||
|
for t, href := range locs {
|
||||||
|
raw, err := e.fetchMemberPath(ctx, *remote, href)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m.Data[t], err = decompress(href, raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("decompress %s: %w", href, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchMemberPath reads one path from a member the same way its own route
|
||||||
|
// would serve it: local index, synthesized remote (e.g. github_rpm), or proxy.
|
||||||
|
func (e *Engine) fetchMemberPath(ctx context.Context, remote models.Remote, path string) ([]byte, error) {
|
||||||
|
prov, err := provider.Get(remote.PackageType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("provider %q: %w", remote.PackageType, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var serve func(w http.ResponseWriter, r *http.Request) bool
|
||||||
|
if remote.RepoType == models.RepoTypeLocal {
|
||||||
|
indexer, ok := prov.(provider.LocalIndexer)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("provider %q does not serve a local index", remote.PackageType)
|
||||||
|
}
|
||||||
|
serve = func(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
return indexer.ServeLocalIndex(w, r, e.db, remote.Name, path)
|
||||||
|
}
|
||||||
|
} else if rs, ok := prov.(provider.RemoteServer); ok {
|
||||||
|
serve = func(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
return rs.ServeRemote(w, r, remote, path, "", e.db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if serve != nil {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if serve(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/"+path, nil)) {
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("%s/%s: status %d", remote.Name, path, rec.Code)
|
||||||
|
}
|
||||||
|
return rec.Body.Bytes(), nil
|
||||||
|
}
|
||||||
|
if remote.RepoType == models.RepoTypeLocal {
|
||||||
|
return nil, fmt.Errorf("%s/%s: %w", remote.Name, path, ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := e.proxyEngine.Fetch(ctx, remote, path, prov)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch %s/%s: %w", remote.Name, path, err)
|
||||||
|
}
|
||||||
|
defer res.Reader.Close()
|
||||||
|
return io.ReadAll(res.Reader)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package virtual
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fakeEngine(members map[string]*RPMMember) *Engine {
|
||||||
|
return &Engine{
|
||||||
|
getRemote: func(_ context.Context, name string) (*models.Remote, error) {
|
||||||
|
return &models.Remote{Name: name, RepoType: models.RepoTypeRemote}, nil
|
||||||
|
},
|
||||||
|
rpmMember: func(_ context.Context, name string) (*RPMMember, error) {
|
||||||
|
if m := members[name]; m != nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("upstream down")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpmVirt(members ...string) models.Virtual {
|
||||||
|
return models.Virtual{Name: "v", PackageType: models.PackageRPM, Members: members}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRPMFailsClosed(t *testing.T) {
|
||||||
|
e := fakeEngine(map[string]*RPMMember{
|
||||||
|
"a": {RemoteName: "a", Data: map[string][]byte{"primary": primaryXML(primaryPkgXML("foo", "1", "aaa", "foo.rpm"))}},
|
||||||
|
})
|
||||||
|
_, _, err := e.Fetch(context.Background(), rpmVirt("a", "b"), "repodata/repomd.xml", "")
|
||||||
|
if err == nil || errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("want upstream error with a member down, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRPMDataSurvivesMemberChange(t *testing.T) {
|
||||||
|
m := &RPMMember{RemoteName: "a", Data: map[string][]byte{"primary": primaryXML(primaryPkgXML("foo", "1", "aaa", "foo.rpm"))}}
|
||||||
|
e := fakeEngine(map[string]*RPMMember{"a": m})
|
||||||
|
virt := rpmVirt("a")
|
||||||
|
|
||||||
|
repomd, _, err := e.Fetch(context.Background(), virt, "repodata/repomd.xml", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Data = map[string][]byte{"primary": primaryXML(primaryPkgXML("foo", "2", "bbb", "foo2.rpm"))}
|
||||||
|
|
||||||
|
href := regexp.MustCompile(`repodata/[0-9a-f]+-primary\.xml\.gz`).Find(repomd)
|
||||||
|
body, _, err := e.Fetch(context.Background(), virt, string(href), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("data from previous repomd must stay resolvable: %v", err)
|
||||||
|
}
|
||||||
|
if got := string(gunzip(t, body)); !regexp.MustCompile(`ver="1"`).MatchString(got) {
|
||||||
|
t.Fatalf("served wrong primary:\n%s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMD, _, err := e.Fetch(context.Background(), virt, "repodata/repomd.xml", "")
|
||||||
|
if err != nil || string(newMD) == string(repomd) {
|
||||||
|
t.Fatalf("repomd should reflect the member change (err %v)", err)
|
||||||
|
}
|
||||||
|
if _, _, err := e.Fetch(context.Background(), virt, string(href), ""); err != nil {
|
||||||
|
t.Fatalf("previous generation must survive one re-merge: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRPMLocalMemberWithoutRepodataIsUpstreamError(t *testing.T) {
|
||||||
|
e := fakeEngine(map[string]*RPMMember{
|
||||||
|
"a": {RemoteName: "a", Data: map[string][]byte{"primary": primaryXML(primaryPkgXML("foo", "1", "aaa", "foo.rpm"))}},
|
||||||
|
})
|
||||||
|
e.rpmMember = func(_ context.Context, name string) (*RPMMember, error) {
|
||||||
|
if name == "local" {
|
||||||
|
return nil, fmt.Errorf("local/repodata/repomd.xml: %w", ErrNotFound)
|
||||||
|
}
|
||||||
|
return &RPMMember{RemoteName: name, Data: map[string][]byte{"primary": primaryXML(primaryPkgXML("foo", "1", "aaa", "foo.rpm"))}}, nil
|
||||||
|
}
|
||||||
|
_, _, err := e.Fetch(context.Background(), rpmVirt("a", "local"), "repodata/repomd.xml", "")
|
||||||
|
if err == nil || errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("member failure must not surface as not-found, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRPMMergeIsShared(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
e := fakeEngine(nil)
|
||||||
|
e.mergeTTL = time.Minute
|
||||||
|
e.rpmMember = func(_ context.Context, name string) (*RPMMember, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
return &RPMMember{RemoteName: name, Data: map[string][]byte{"primary": primaryXML(primaryPkgXML("foo", "1", "aaa", "foo.rpm"))}}, nil
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 10 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, _, err := e.Fetch(context.Background(), rpmVirt("a"), "repodata/repomd.xml", ""); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if _, _, err := e.Fetch(context.Background(), rpmVirt("a"), "repodata/repomd.xml", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := calls.Load(); n != 1 {
|
||||||
|
t.Fatalf("member fetched %d times, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRPMDataScopedToVirtual(t *testing.T) {
|
||||||
|
e := fakeEngine(map[string]*RPMMember{
|
||||||
|
"a": {RemoteName: "a", Data: map[string][]byte{"primary": primaryXML(primaryPkgXML("foo", "1", "aaa", "foo.rpm"))}},
|
||||||
|
"b": {RemoteName: "b", Data: map[string][]byte{"primary": primaryXML(primaryPkgXML("bar", "1", "bbb", "bar.rpm"))}},
|
||||||
|
})
|
||||||
|
virtA := models.Virtual{Name: "va", PackageType: models.PackageRPM, Members: []string{"a"}}
|
||||||
|
virtB := models.Virtual{Name: "vb", PackageType: models.PackageRPM, Members: []string{"b"}}
|
||||||
|
|
||||||
|
repomd, _, err := e.Fetch(context.Background(), virtA, "repodata/repomd.xml", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
href := regexp.MustCompile(`repodata/[0-9a-f]+-primary\.xml\.gz`).Find(repomd)
|
||||||
|
if _, _, err := e.Fetch(context.Background(), virtB, string(href), ""); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("virtual vb served va's data file (err %v)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberRedirect(t *testing.T) {
|
||||||
|
e := fakeEngine(nil)
|
||||||
|
virt := rpmVirt("gh", "other")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path, query, want string
|
||||||
|
ok bool
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{path: "gh/o/r/releases/download/v1/c++-1.rpm", query: "x=1", ok: true,
|
||||||
|
want: "http://h/api/v1/remote/gh/o/r/releases/download/v1/c++-1.rpm?x=1"},
|
||||||
|
{path: "gh/a%20b/c%3Fd.rpm", ok: true, want: "http://h/api/v1/remote/gh/a%20b/c%3Fd.rpm"},
|
||||||
|
{path: "gh/../../remote/other/x", err: ErrBadPath},
|
||||||
|
{path: "gh/%2e%2e/%2e%2e/remote/other/x", err: ErrBadPath},
|
||||||
|
{path: "gh/a/./b", err: ErrBadPath},
|
||||||
|
{path: "gh//etc/passwd", err: ErrBadPath},
|
||||||
|
{path: "repodata/repomd.xml"},
|
||||||
|
{path: "nope/x.rpm"},
|
||||||
|
} {
|
||||||
|
got, ok, err := e.MemberRedirect(context.Background(), virt, tc.path, tc.query, "http://h/")
|
||||||
|
if got != tc.want || ok != tc.ok || !errors.Is(err, tc.err) {
|
||||||
|
t.Errorf("%s: got (%q, %v, %v), want (%q, %v, %v)", tc.path, got, ok, err, tc.want, tc.ok, tc.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package virtual
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/bzip2"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
"github.com/ulikunitz/xz"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rpmDataTypes = []string{"primary", "filelists", "other"}
|
||||||
|
|
||||||
|
var rpmRoots = map[string]string{
|
||||||
|
"primary": `<metadata xmlns="http://linux.duke.edu/metadata/common" xmlns:rpm="http://linux.duke.edu/metadata/rpm" packages="%d">`,
|
||||||
|
"filelists": `<filelists xmlns="http://linux.duke.edu/metadata/filelists" packages="%d">`,
|
||||||
|
"other": `<otherdata xmlns="http://linux.duke.edu/metadata/other" packages="%d">`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var rpmRootClose = map[string]string{"primary": "</metadata>", "filelists": "</filelists>", "other": "</otherdata>"}
|
||||||
|
|
||||||
|
var locationRe = regexp.MustCompile(`<location\b[^>]*/>`)
|
||||||
|
|
||||||
|
// RPMMember is one member repo's decompressed primary/filelists/other XML,
|
||||||
|
// keyed by repomd data type. Missing types contribute no packages.
|
||||||
|
type RPMMember struct {
|
||||||
|
RemoteName string
|
||||||
|
Bases []string
|
||||||
|
Timestamp int64
|
||||||
|
Data map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPMRepo is a merged yum repo: repomd.xml plus the files it references, keyed
|
||||||
|
// by their path under the repo root.
|
||||||
|
type RPMRepo struct {
|
||||||
|
Repomd []byte
|
||||||
|
Files map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type repomdDoc struct {
|
||||||
|
Revision string `xml:"revision"`
|
||||||
|
Data []repomdData `xml:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type repomdData struct {
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Location struct {
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
} `xml:"location"`
|
||||||
|
Timestamp int64 `xml:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpmPkgVersion struct {
|
||||||
|
Epoch string `xml:"epoch,attr"`
|
||||||
|
Ver string `xml:"ver,attr"`
|
||||||
|
Rel string `xml:"rel,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type primaryPkg struct {
|
||||||
|
Name string `xml:"name"`
|
||||||
|
Arch string `xml:"arch"`
|
||||||
|
Version rpmPkgVersion `xml:"version"`
|
||||||
|
Checksum string `xml:"checksum"`
|
||||||
|
Location struct {
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
Base string `xml:"http://www.w3.org/XML/1998/namespace base,attr"`
|
||||||
|
} `xml:"location"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pkgidPkg struct {
|
||||||
|
PkgID string `xml:"pkgid,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRepomd returns the member's primary/filelists/other locations and its
|
||||||
|
// newest data timestamp.
|
||||||
|
func parseRepomd(body []byte) (map[string]string, int64, error) {
|
||||||
|
var doc repomdDoc
|
||||||
|
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("parse repomd.xml: %w", err)
|
||||||
|
}
|
||||||
|
locs := map[string]string{}
|
||||||
|
var ts int64
|
||||||
|
for _, d := range doc.Data {
|
||||||
|
for _, t := range rpmDataTypes {
|
||||||
|
if d.Type == t && d.Location.Href != "" {
|
||||||
|
locs[t] = d.Location.Href
|
||||||
|
ts = max(ts, d.Timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if locs["primary"] == "" {
|
||||||
|
return nil, 0, errors.New("repomd.xml has no primary data")
|
||||||
|
}
|
||||||
|
return locs, ts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decompress(href string, body []byte) ([]byte, error) {
|
||||||
|
var r io.Reader
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(href, ".gz"):
|
||||||
|
gz, err := gzip.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r = gz
|
||||||
|
case strings.HasSuffix(href, ".xz"):
|
||||||
|
x, err := xz.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r = x
|
||||||
|
case strings.HasSuffix(href, ".zst"):
|
||||||
|
z, err := zstd.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer z.Close()
|
||||||
|
r = z
|
||||||
|
case strings.HasSuffix(href, ".bz2"):
|
||||||
|
r = bzip2.NewReader(bytes.NewReader(body))
|
||||||
|
default:
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
return io.ReadAll(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitPackages returns the raw bytes of each top-level <package> element.
|
||||||
|
func splitPackages(doc []byte) ([][]byte, error) {
|
||||||
|
d := xml.NewDecoder(bytes.NewReader(doc))
|
||||||
|
var pkgs [][]byte
|
||||||
|
depth := 0
|
||||||
|
for {
|
||||||
|
start := d.InputOffset()
|
||||||
|
tok, err := d.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
return pkgs, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch t := tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if depth == 1 && t.Name.Local == "package" {
|
||||||
|
if err := d.Skip(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, doc[start:d.InputOffset()])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
depth++
|
||||||
|
case xml.EndElement:
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberHref returns href relative to the member root. An xml:base under one of
|
||||||
|
// the member's upstream bases is folded into the href; any other xml:base is a
|
||||||
|
// host the member can't serve, so ok is false and the location is left as is.
|
||||||
|
func memberHref(m RPMMember, base, href string) (string, bool) {
|
||||||
|
href = strings.TrimLeft(href, "/")
|
||||||
|
if base == "" {
|
||||||
|
return href, true
|
||||||
|
}
|
||||||
|
base = strings.TrimRight(base, "/") + "/"
|
||||||
|
for _, b := range m.Bases {
|
||||||
|
b = strings.TrimRight(b, "/") + "/"
|
||||||
|
if strings.HasPrefix(base, b) {
|
||||||
|
return strings.TrimPrefix(base, b) + href, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func xmlEscape(s string) string {
|
||||||
|
var b bytes.Buffer
|
||||||
|
_ = xml.EscapeText(&b, []byte(s))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeRPM merges members into one repo. Members are in priority order: the
|
||||||
|
// first member to carry a NEVRA wins. Package locations are prefixed with the
|
||||||
|
// owning member's name so the virtual can route downloads back to it.
|
||||||
|
func MergeRPM(members []RPMMember) (*RPMRepo, error) {
|
||||||
|
kept := map[string]bool{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := map[string][][]byte{}
|
||||||
|
|
||||||
|
for i, m := range members {
|
||||||
|
pkgs, err := splitPackages(m.Data["primary"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("member %q primary: %w", m.RemoteName, err)
|
||||||
|
}
|
||||||
|
for _, raw := range pkgs {
|
||||||
|
var p primaryPkg
|
||||||
|
if err := xml.Unmarshal(raw, &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("member %q primary package: %w", m.RemoteName, err)
|
||||||
|
}
|
||||||
|
epoch := p.Version.Epoch
|
||||||
|
if epoch == "" {
|
||||||
|
epoch = "0"
|
||||||
|
}
|
||||||
|
nevra := fmt.Sprintf("%s-%s:%s-%s.%s", p.Name, epoch, p.Version.Ver, p.Version.Rel, p.Arch)
|
||||||
|
if seen[nevra] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[nevra] = true
|
||||||
|
kept[fmt.Sprintf("%d/%s", i, p.Checksum)] = true
|
||||||
|
|
||||||
|
if href, ok := memberHref(m, p.Location.Base, p.Location.Href); ok {
|
||||||
|
loc := `<location href="` + xmlEscape(m.RemoteName+"/"+href) + `"/>`
|
||||||
|
raw = locationRe.ReplaceAll(raw, []byte(loc))
|
||||||
|
}
|
||||||
|
out["primary"] = append(out["primary"], raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range rpmDataTypes[1:] {
|
||||||
|
emitted := map[string]bool{}
|
||||||
|
for i, m := range members {
|
||||||
|
pkgs, err := splitPackages(m.Data[t])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("member %q %s: %w", m.RemoteName, t, err)
|
||||||
|
}
|
||||||
|
for _, raw := range pkgs {
|
||||||
|
var p pkgidPkg
|
||||||
|
if err := xml.Unmarshal(raw, &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("member %q %s package: %w", m.RemoteName, t, err)
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%d/%s", i, p.PkgID)
|
||||||
|
if kept[key] && !emitted[key] {
|
||||||
|
emitted[key] = true
|
||||||
|
out[t] = append(out[t], raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ts int64
|
||||||
|
for _, m := range members {
|
||||||
|
ts = max(ts, m.Timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := &RPMRepo{Files: map[string][]byte{}}
|
||||||
|
var md bytes.Buffer
|
||||||
|
md.WriteString(xml.Header)
|
||||||
|
md.WriteString(`<repomd xmlns="http://linux.duke.edu/metadata/repo" xmlns:rpm="http://linux.duke.edu/metadata/rpm">` + "\n")
|
||||||
|
fmt.Fprintf(&md, " <revision>%d</revision>\n", ts)
|
||||||
|
for _, t := range rpmDataTypes {
|
||||||
|
var doc bytes.Buffer
|
||||||
|
doc.WriteString(xml.Header)
|
||||||
|
fmt.Fprintf(&doc, rpmRoots[t]+"\n", len(out[t]))
|
||||||
|
for _, raw := range out[t] {
|
||||||
|
doc.Write(raw)
|
||||||
|
doc.WriteString("\n")
|
||||||
|
}
|
||||||
|
doc.WriteString(rpmRootClose[t] + "\n")
|
||||||
|
|
||||||
|
gz := gzipDeterministic(doc.Bytes())
|
||||||
|
sum := sha256Hex(gz)
|
||||||
|
href := fmt.Sprintf("repodata/%s-%s.xml.gz", sum, t)
|
||||||
|
repo.Files[href] = gz
|
||||||
|
|
||||||
|
fmt.Fprintf(&md, " <data type=\"%s\">\n", t)
|
||||||
|
fmt.Fprintf(&md, " <checksum type=\"sha256\">%s</checksum>\n", sum)
|
||||||
|
fmt.Fprintf(&md, " <open-checksum type=\"sha256\">%s</open-checksum>\n", sha256Hex(doc.Bytes()))
|
||||||
|
fmt.Fprintf(&md, " <location href=\"%s\"/>\n", href)
|
||||||
|
fmt.Fprintf(&md, " <timestamp>%d</timestamp>\n", ts)
|
||||||
|
fmt.Fprintf(&md, " <size>%d</size>\n", len(gz))
|
||||||
|
fmt.Fprintf(&md, " <open-size>%d</open-size>\n", doc.Len())
|
||||||
|
md.WriteString(" </data>\n")
|
||||||
|
}
|
||||||
|
md.WriteString("</repomd>\n")
|
||||||
|
repo.Repomd = md.Bytes()
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gzipDeterministic(data []byte) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
gz.Header = gzip.Header{OS: 255}
|
||||||
|
_, _ = gz.Write(data)
|
||||||
|
_ = gz.Close()
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Hex(data []byte) string {
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package virtual
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
"github.com/ulikunitz/xz"
|
||||||
|
)
|
||||||
|
|
||||||
|
func primaryXML(pkgs ...string) []byte {
|
||||||
|
return []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata xmlns="http://linux.duke.edu/metadata/common" xmlns:rpm="http://linux.duke.edu/metadata/rpm" packages="` + fmt.Sprint(len(pkgs)) + `">
|
||||||
|
` + strings.Join(pkgs, "\n") + `
|
||||||
|
</metadata>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryPkgXML(name, ver, pkgid, href string) string {
|
||||||
|
return `<package type="rpm">
|
||||||
|
<name>` + name + `</name>
|
||||||
|
<arch>x86_64</arch>
|
||||||
|
<version epoch="0" ver="` + ver + `" rel="1"/>
|
||||||
|
<checksum type="sha256" pkgid="YES">` + pkgid + `</checksum>
|
||||||
|
<location href="` + href + `"/>
|
||||||
|
<format>
|
||||||
|
<rpm:provides><rpm:entry name="` + name + `"/></rpm:provides>
|
||||||
|
</format>
|
||||||
|
</package>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func filelistsXML(pkgids ...string) []byte {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<?xml version="1.0"?><filelists xmlns="http://linux.duke.edu/metadata/filelists" packages="1">`)
|
||||||
|
for _, id := range pkgids {
|
||||||
|
b.WriteString(`<package pkgid="` + id + `" name="x" arch="x86_64"><version epoch="0" ver="1" rel="1"/><file>/usr/bin/` + id + `</file></package>`)
|
||||||
|
}
|
||||||
|
b.WriteString(`</filelists>`)
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func gunzip(t *testing.T, b []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
r, err := gzip.NewReader(bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergedFile(t *testing.T, repo *RPMRepo, dtype string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
for href, body := range repo.Files {
|
||||||
|
if strings.HasSuffix(href, "-"+dtype+".xml.gz") {
|
||||||
|
return gunzip(t, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no %s file in merged repo", dtype)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeRPMDedupePriority(t *testing.T) {
|
||||||
|
members := []RPMMember{
|
||||||
|
{RemoteName: "first", Timestamp: 100, Data: map[string][]byte{
|
||||||
|
"primary": primaryXML(primaryPkgXML("foo", "1.0", "aaa", "Packages/foo-1.0.rpm")),
|
||||||
|
"filelists": filelistsXML("aaa"),
|
||||||
|
}},
|
||||||
|
{RemoteName: "second", Timestamp: 200, Data: map[string][]byte{
|
||||||
|
"primary": primaryXML(
|
||||||
|
primaryPkgXML("foo", "1.0", "bbb", "Packages/foo-1.0-rebuilt.rpm"),
|
||||||
|
primaryPkgXML("bar", "2.0", "ccc", "Packages/bar-2.0.rpm"),
|
||||||
|
),
|
||||||
|
"filelists": filelistsXML("bbb", "ccc"),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
repo, err := MergeRPM(members)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
primary := string(mergedFile(t, repo, "primary"))
|
||||||
|
if strings.Count(primary, "<name>foo</name>") != 1 {
|
||||||
|
t.Fatalf("duplicate NEVRA not deduped:\n%s", primary)
|
||||||
|
}
|
||||||
|
if !strings.Contains(primary, `href="first/Packages/foo-1.0.rpm"`) || strings.Contains(primary, "foo-1.0-rebuilt") {
|
||||||
|
t.Fatalf("first member should win duplicate NEVRA:\n%s", primary)
|
||||||
|
}
|
||||||
|
if !strings.Contains(primary, `href="second/Packages/bar-2.0.rpm"`) {
|
||||||
|
t.Fatalf("unique package from second member missing:\n%s", primary)
|
||||||
|
}
|
||||||
|
if !strings.Contains(primary, `packages="2"`) {
|
||||||
|
t.Fatalf("package count wrong:\n%s", primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
filelists := string(mergedFile(t, repo, "filelists"))
|
||||||
|
if !strings.Contains(filelists, "/usr/bin/aaa") || !strings.Contains(filelists, "/usr/bin/ccc") || strings.Contains(filelists, "/usr/bin/bbb") {
|
||||||
|
t.Fatalf("filelists must follow the winning primary entries:\n%s", filelists)
|
||||||
|
}
|
||||||
|
|
||||||
|
other := string(mergedFile(t, repo, "other"))
|
||||||
|
if !strings.Contains(other, `packages="0"`) {
|
||||||
|
t.Fatalf("members without other data should yield an empty other.xml:\n%s", other)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(repo.Repomd), "<revision>200</revision>") {
|
||||||
|
t.Fatalf("revision should be the newest member timestamp:\n%s", repo.Repomd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeRPMOutputIsWellFormed(t *testing.T) {
|
||||||
|
repo, err := MergeRPM([]RPMMember{{RemoteName: "a", Data: map[string][]byte{
|
||||||
|
"primary": primaryXML(primaryPkgXML("foo", "1.0", "aaa", "Packages/foo.rpm")),
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
XMLName xml.Name
|
||||||
|
Packages []struct {
|
||||||
|
Name string `xml:"name"`
|
||||||
|
Provides []struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
} `xml:"format>provides>entry"`
|
||||||
|
} `xml:"package"`
|
||||||
|
}
|
||||||
|
if err := xml.Unmarshal(mergedFile(t, repo, "primary"), &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if doc.XMLName.Space != "http://linux.duke.edu/metadata/common" || len(doc.Packages) != 1 {
|
||||||
|
t.Fatalf("unexpected merged primary: %+v", doc)
|
||||||
|
}
|
||||||
|
if len(doc.Packages[0].Provides) != 1 || doc.Packages[0].Provides[0].Name != "foo" {
|
||||||
|
t.Fatalf("rpm: namespaced format lost: %+v", doc.Packages[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeRPMHrefRewriting(t *testing.T) {
|
||||||
|
based := func(name, base string) string {
|
||||||
|
return `<package type="rpm"><name>` + name + `</name><arch>noarch</arch><version epoch="0" ver="1" rel="1"/>` +
|
||||||
|
`<checksum type="sha256" pkgid="YES">` + name + `</checksum><location xml:base="` + base + `" href="Packages/` + name + `.rpm"/></package>`
|
||||||
|
}
|
||||||
|
repo, err := MergeRPM([]RPMMember{{RemoteName: "gh", Bases: []string{"https://up.example/el9", "https://mirror.example/el9/"}, Data: map[string][]byte{
|
||||||
|
"primary": primaryXML(
|
||||||
|
primaryPkgXML("foo", "1.0", "aaa", "storytold/photocraft/releases/download/v1.0/foo&bar.rpm"),
|
||||||
|
primaryPkgXML("lead", "1.0", "bbb", "/Packages/lead.rpm"),
|
||||||
|
based("root", "https://up.example/el9/"),
|
||||||
|
based("sub", "https://mirror.example/el9/extra"),
|
||||||
|
based("ext", "https://other.example/"),
|
||||||
|
),
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
primary := string(mergedFile(t, repo, "primary"))
|
||||||
|
for _, want := range []string{
|
||||||
|
`<location href="gh/storytold/photocraft/releases/download/v1.0/foo&bar.rpm"/>`,
|
||||||
|
`<location href="gh/Packages/lead.rpm"/>`,
|
||||||
|
`<location href="gh/Packages/root.rpm"/>`,
|
||||||
|
`<location href="gh/extra/Packages/sub.rpm"/>`,
|
||||||
|
`<location xml:base="https://other.example/" href="Packages/ext.rpm"/>`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(primary, want) {
|
||||||
|
t.Errorf("missing %s in:\n%s", want, primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeRPMChecksums(t *testing.T) {
|
||||||
|
repo, err := MergeRPM([]RPMMember{{RemoteName: "a", Timestamp: 42, Data: map[string][]byte{
|
||||||
|
"primary": primaryXML(primaryPkgXML("foo", "1.0", "aaa", "Packages/foo.rpm")),
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var md struct {
|
||||||
|
Data []struct {
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Checksum string `xml:"checksum"`
|
||||||
|
OpenChecksum string `xml:"open-checksum"`
|
||||||
|
Location struct {
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
} `xml:"location"`
|
||||||
|
Timestamp int64 `xml:"timestamp"`
|
||||||
|
Size int `xml:"size"`
|
||||||
|
OpenSize int `xml:"open-size"`
|
||||||
|
} `xml:"data"`
|
||||||
|
}
|
||||||
|
if err := xml.Unmarshal(repo.Repomd, &md); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(md.Data) != 3 {
|
||||||
|
t.Fatalf("want primary/filelists/other, got %d entries", len(md.Data))
|
||||||
|
}
|
||||||
|
for _, d := range md.Data {
|
||||||
|
body, ok := repo.Files[d.Location.Href]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s: location %q not served", d.Type, d.Location.Href)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(body)
|
||||||
|
if hex.EncodeToString(sum[:]) != d.Checksum || len(body) != d.Size {
|
||||||
|
t.Errorf("%s: checksum/size do not match served bytes", d.Type)
|
||||||
|
}
|
||||||
|
open := gunzip(t, body)
|
||||||
|
osum := sha256.Sum256(open)
|
||||||
|
if hex.EncodeToString(osum[:]) != d.OpenChecksum || len(open) != d.OpenSize {
|
||||||
|
t.Errorf("%s: open-checksum/open-size do not match decompressed bytes", d.Type)
|
||||||
|
}
|
||||||
|
if d.Timestamp != 42 {
|
||||||
|
t.Errorf("%s: timestamp %d, want 42", d.Type, d.Timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
again, _ := MergeRPM([]RPMMember{{RemoteName: "a", Timestamp: 42, Data: map[string][]byte{
|
||||||
|
"primary": primaryXML(primaryPkgXML("foo", "1.0", "aaa", "Packages/foo.rpm")),
|
||||||
|
}}})
|
||||||
|
if !bytes.Equal(repo.Repomd, again.Repomd) {
|
||||||
|
t.Error("merge must be deterministic so repomd checksums stay valid across requests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRepomd(t *testing.T) {
|
||||||
|
locs, ts, err := parseRepomd([]byte(`<repomd xmlns="http://linux.duke.edu/metadata/repo">
|
||||||
|
<data type="primary"><location href="repodata/p-primary.xml.zst"/><timestamp>10</timestamp></data>
|
||||||
|
<data type="primary_db"><location href="repodata/p.sqlite.bz2"/><timestamp>99</timestamp></data>
|
||||||
|
<data type="other"><location href="repodata/o-other.xml.gz"/><timestamp>20</timestamp></data>
|
||||||
|
</repomd>`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if locs["primary"] != "repodata/p-primary.xml.zst" || locs["other"] != "repodata/o-other.xml.gz" || len(locs) != 2 {
|
||||||
|
t.Fatalf("unexpected locations: %v", locs)
|
||||||
|
}
|
||||||
|
if ts != 20 {
|
||||||
|
t.Fatalf("timestamp %d, want 20 (sqlite entries ignored)", ts)
|
||||||
|
}
|
||||||
|
if _, _, err := parseRepomd([]byte(`<repomd><data type="other"><location href="x"/></data></repomd>`)); err == nil {
|
||||||
|
t.Fatal("repomd without primary should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecompress(t *testing.T) {
|
||||||
|
plain := []byte("<metadata/>")
|
||||||
|
|
||||||
|
var xzBuf bytes.Buffer
|
||||||
|
xw, _ := xz.NewWriter(&xzBuf)
|
||||||
|
_, _ = xw.Write(plain)
|
||||||
|
_ = xw.Close()
|
||||||
|
|
||||||
|
zw, _ := zstd.NewWriter(nil)
|
||||||
|
zst := zw.EncodeAll(plain, nil)
|
||||||
|
|
||||||
|
for href, body := range map[string][]byte{
|
||||||
|
"p.xml": plain,
|
||||||
|
"p.xml.gz": gzipDeterministic(plain),
|
||||||
|
"p.xml.xz": xzBuf.Bytes(),
|
||||||
|
"p.xml.zst": zst,
|
||||||
|
} {
|
||||||
|
got, err := decompress(href, body)
|
||||||
|
if err != nil || !bytes.Equal(got, plain) {
|
||||||
|
t.Errorf("%s: got %q, %v", href, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Failed GitHub release scans retry with backoff instead of waiting out a full
|
||||||
|
-- mutable_ttl: sync_failures counts consecutive failures, next_retry_at gates
|
||||||
|
-- the next claim while a retry is pending.
|
||||||
|
ALTER TABLE github_rpm_sync_state ADD COLUMN IF NOT EXISTS sync_failures INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE github_rpm_sync_state ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE github_deb_sync_state ADD COLUMN IF NOT EXISTS sync_failures INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE github_deb_sync_state ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE github_alpine_sync_state ADD COLUMN IF NOT EXISTS sync_failures INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE github_alpine_sync_state ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Local cargo registries: one sparse-index line per published crate version.
|
||||||
|
-- line holds the index JSON minus yanked, which flips on yank/unyank.
|
||||||
|
CREATE TABLE IF NOT EXISTS cargo_index (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
repo_name TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
line TEXT NOT NULL,
|
||||||
|
yanked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(repo_name, file_path)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cargo_index_repo_name ON cargo_index(repo_name, lower(name));
|
||||||
|
|
||||||
|
-- Cargo treats names equal up to case and -/_, and versions equal up to build metadata.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_cargo_index_unique_version
|
||||||
|
ON cargo_index(repo_name, replace(lower(name), '-', '_'), split_part(version, '+', 1));
|
||||||
@@ -16,6 +16,8 @@ const (
|
|||||||
PackagePuppet PackageType = "puppet"
|
PackagePuppet PackageType = "puppet"
|
||||||
PackageTerraform PackageType = "terraform"
|
PackageTerraform PackageType = "terraform"
|
||||||
PackageGoProxy PackageType = "goproxy"
|
PackageGoProxy PackageType = "goproxy"
|
||||||
|
PackageCargo PackageType = "cargo"
|
||||||
|
PackageNuGet PackageType = "nuget"
|
||||||
PackageGitHubRPM PackageType = "github_rpm"
|
PackageGitHubRPM PackageType = "github_rpm"
|
||||||
PackageGitHubDeb PackageType = "github_deb"
|
PackageGitHubDeb PackageType = "github_deb"
|
||||||
PackageGitHubAlpine PackageType = "github_alpine"
|
PackageGitHubAlpine PackageType = "github_alpine"
|
||||||
@@ -33,6 +35,8 @@ var validPackageTypes = map[PackageType]bool{
|
|||||||
PackagePuppet: true,
|
PackagePuppet: true,
|
||||||
PackageTerraform: true,
|
PackageTerraform: true,
|
||||||
PackageGoProxy: true,
|
PackageGoProxy: true,
|
||||||
|
PackageCargo: true,
|
||||||
|
PackageNuGet: true,
|
||||||
PackageGitHubRPM: true,
|
PackageGitHubRPM: true,
|
||||||
PackageGitHubDeb: true,
|
PackageGitHubDeb: true,
|
||||||
PackageGitHubAlpine: true,
|
PackageGitHubAlpine: true,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ func TestPackageTypeValid(t *testing.T) {
|
|||||||
models.PackagePuppet,
|
models.PackagePuppet,
|
||||||
models.PackageTerraform,
|
models.PackageTerraform,
|
||||||
models.PackageGoProxy,
|
models.PackageGoProxy,
|
||||||
|
models.PackageCargo,
|
||||||
|
models.PackageNuGet,
|
||||||
models.PackageGitHubRPM,
|
models.PackageGitHubRPM,
|
||||||
models.PackageGitHubDeb,
|
models.PackageGitHubDeb,
|
||||||
models.PackageGitHubAlpine,
|
models.PackageGitHubAlpine,
|
||||||
|
|||||||
@@ -228,6 +228,56 @@ go mod download`,
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
case 'cargo':
|
||||||
|
if (isLocal) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Add the private registry (real sparse registry)',
|
||||||
|
language: 'toml',
|
||||||
|
code: `# $CARGO_HOME/config.toml
|
||||||
|
[registries.${name}]
|
||||||
|
index = "sparse+${url}/api/v1/local/${name}/"`,
|
||||||
|
note: 'cargo requires a token for publish/yank: set CARGO_REGISTRIES_' + name.toUpperCase().replace(/-/g, '_') + '_TOKEN to any value.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Publish, depend on and yank crates',
|
||||||
|
language: 'bash',
|
||||||
|
code: `cargo publish --registry ${name}
|
||||||
|
cargo add <crate> --registry ${name}
|
||||||
|
cargo yank --version <version> --registry ${name}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Point crates.io at this sparse registry',
|
||||||
|
language: 'toml',
|
||||||
|
code: `# ~/.cargo/config.toml
|
||||||
|
[source.crates-io]
|
||||||
|
replace-with = "${name}"
|
||||||
|
|
||||||
|
[registries.${name}]
|
||||||
|
index = "sparse+${proxy}/"`,
|
||||||
|
note: 'config.json is served by artifactapi so crate downloads also go through this remote.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'nuget':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Use this feed instead of nuget.org',
|
||||||
|
language: 'xml',
|
||||||
|
code: `<!-- nuget.config -->
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="${name}" value="${proxy}/index.json" protocolVersion="3" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>`,
|
||||||
|
note: 'Serve artifactapi over HTTPS: NuGet rejects the repository signatures resource over plain HTTP.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
case 'puppet':
|
case 'puppet':
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
|||||||
puppet: 'yellow',
|
puppet: 'yellow',
|
||||||
terraform: 'blue',
|
terraform: 'blue',
|
||||||
goproxy: 'green',
|
goproxy: 'green',
|
||||||
|
cargo: 'yellow',
|
||||||
|
nuget: 'blue',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Locals() {
|
export function Locals() {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
|||||||
puppet: 'yellow',
|
puppet: 'yellow',
|
||||||
terraform: 'blue',
|
terraform: 'blue',
|
||||||
goproxy: 'green',
|
goproxy: 'green',
|
||||||
|
cargo: 'yellow',
|
||||||
|
nuget: 'blue',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Remotes() {
|
export function Remotes() {
|
||||||
|
|||||||
Reference in New Issue
Block a user