Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9387bce560 | |||
| 7769902530 | |||
| 3dd4acd233 | |||
| bc68eb75fc |
@@ -1,6 +1,6 @@
|
||||
# ArtifactAPI
|
||||
|
||||
Caching proxy for package repositories. Single Go binary, 11 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
|
||||
|
||||
@@ -33,6 +33,8 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173`
|
||||
| `terraform` | `*/versions` | `*/download/*/*` |
|
||||
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
|
||||
| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` |
|
||||
| `gems` | compact index (`versions`, `info/*`, `names`), `*specs.4.8.gz` | `gems/*.gem`, `quick/Marshal.4.8/*.gemspec.rz` |
|
||||
| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` |
|
||||
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
|
||||
|
||||
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
||||
|
||||
@@ -23,7 +23,7 @@ already-running stack.
|
||||
|
||||
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
||||
- **Caching** — one immutable artifact per remote package type (generic, docker,
|
||||
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo) proxied through the
|
||||
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, gems, nuget) proxied through the
|
||||
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||
verified against the origin fixture.
|
||||
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
||||
|
||||
@@ -30,6 +30,8 @@ func TestCachingPerProvider(t *testing.T) {
|
||||
{"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"},
|
||||
{"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"},
|
||||
{"gems", "gems/mygem-1.0.0.gem", "gems/mygem-1.0.0.gem"},
|
||||
{"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"},
|
||||
{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
mygem-1.0.0 e2e fixture
|
||||
Binary file not shown.
@@ -0,0 +1,168 @@
|
||||
//go:build e2e
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||
)
|
||||
|
||||
// assertHeadMatchesGet checks HEAD returns GET's status, type and length with no body.
|
||||
func assertHeadMatchesGet(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
get, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(get.Body)
|
||||
get.Body.Close()
|
||||
if get.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s: %d %s", url, get.StatusCode, body)
|
||||
}
|
||||
|
||||
head, err := http.Head(url)
|
||||
if err != nil {
|
||||
t.Fatalf("HEAD: %v", err)
|
||||
}
|
||||
hb, _ := io.ReadAll(head.Body)
|
||||
head.Body.Close()
|
||||
if head.StatusCode != http.StatusOK {
|
||||
t.Fatalf("HEAD %s: got %d, want 200", url, head.StatusCode)
|
||||
}
|
||||
if len(hb) != 0 {
|
||||
t.Errorf("HEAD returned a %d-byte body", len(hb))
|
||||
}
|
||||
if head.Header.Get("Content-Type") != get.Header.Get("Content-Type") {
|
||||
t.Errorf("HEAD Content-Type %q, GET %q", head.Header.Get("Content-Type"), get.Header.Get("Content-Type"))
|
||||
}
|
||||
if head.ContentLength != int64(len(body)) {
|
||||
t.Errorf("HEAD Content-Length %d, GET body %d bytes", head.ContentLength, len(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHeadNuGetRewritten(t *testing.T) {
|
||||
var upstream *httptest.Server
|
||||
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"version": "3.0.0", "resources": []map[string]string{
|
||||
{"@id": upstream.URL + "/v3-flatcontainer/", "@type": "PackageBaseAddress/3.0.0"},
|
||||
}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
createRemote(t, mustFormat(`{"name": "head-nuget", "package_type": "nuget", "base_url": %q}`, upstream.URL+"/v3/index.json"))
|
||||
defer deleteRemote(t, "head-nuget")
|
||||
|
||||
assertHeadMatchesGet(t, apiURL("/api/v1/remote/head-nuget/index.json"))
|
||||
}
|
||||
|
||||
func TestProxyHeadGitHubRPMRepodata(t *testing.T) {
|
||||
rpm := testsupport.MinimalRPM("demo", "1.2", "3", "x86_64")
|
||||
var gh *httptest.Server
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/repos/acme/tools/releases", func(w http.ResponseWriter, r *http.Request) {
|
||||
if p := r.URL.Query().Get("page"); p != "" && p != "1" {
|
||||
w.Write([]byte("[]"))
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode([]map[string]any{{"tag_name": "v1.2-3", "assets": []map[string]any{{
|
||||
"name": "demo-1.2-3.x86_64.rpm",
|
||||
"size": len(rpm),
|
||||
"browser_download_url": gh.URL + "/acme/tools/releases/download/v1.2-3/demo-1.2-3.x86_64.rpm",
|
||||
}}}})
|
||||
})
|
||||
mux.HandleFunc("/acme/tools/releases/download/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(rpm)))
|
||||
w.Write(rpm)
|
||||
})
|
||||
gh = httptest.NewServer(mux)
|
||||
defer gh.Close()
|
||||
|
||||
createRemote(t, mustFormat(`{"name": "head-ghrpm", "package_type": "github_rpm", "base_url": %q, "releases_remote": "github"}`, gh.URL+"/repos/acme/tools"))
|
||||
defer deleteRemote(t, "head-ghrpm")
|
||||
|
||||
url := apiURL("/api/v1/remote/head-ghrpm/repodata/repomd.xml")
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
if _, status := getBody(t, url); status == http.StatusOK || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
assertHeadMatchesGet(t, url)
|
||||
}
|
||||
|
||||
func TestProxyHeadNpmUncachedPackument(t *testing.T) {
|
||||
const abbreviated = "application/vnd.npm.install-v1+json"
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
doc := map[string]any{"name": "left-pad", "versions": map[string]any{"1.3.0": map[string]any{}}}
|
||||
if r.Header.Get("Accept") != abbreviated {
|
||||
doc["readme"] = strings.Repeat("full packument ", 200)
|
||||
}
|
||||
json.NewEncoder(w).Encode(doc)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
createRemote(t, mustFormat(`{"name": "head-npm", "package_type": "npm", "base_url": %q}`, upstream.URL))
|
||||
defer deleteRemote(t, "head-npm")
|
||||
|
||||
url := apiURL("/api/v1/remote/head-npm/left-pad")
|
||||
do := func(method string) (*http.Response, []byte) {
|
||||
req, _ := http.NewRequest(method, url, nil)
|
||||
req.Header.Set("Accept", abbreviated)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
head, _ := do(http.MethodHead)
|
||||
get, body := do(http.MethodGet)
|
||||
if head.StatusCode != http.StatusOK || get.StatusCode != http.StatusOK {
|
||||
t.Fatalf("HEAD %d, GET %d", head.StatusCode, get.StatusCode)
|
||||
}
|
||||
if head.ContentLength != int64(len(body)) {
|
||||
t.Errorf("uncached HEAD Content-Length %d, GET body %d bytes", head.ContentLength, len(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHeadNonRewritingUsesUpstreamHead(t *testing.T) {
|
||||
methods := make(chan string, 4)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
methods <- r.Method
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.Write([]byte("<repomd/>"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
createRemote(t, mustFormat(`{"name": "head-rpm", "package_type": "rpm", "base_url": %q}`, upstream.URL))
|
||||
defer deleteRemote(t, "head-rpm")
|
||||
|
||||
resp, err := http.Head(apiURL("/api/v1/remote/head-rpm/repodata/repomd.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("HEAD: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || resp.Header.Get("X-Artifact-Source") != "remote" {
|
||||
t.Fatalf("HEAD %d source %q", resp.StatusCode, resp.Header.Get("X-Artifact-Source"))
|
||||
}
|
||||
close(methods)
|
||||
var seen []string
|
||||
for m := range methods {
|
||||
seen = append(seen, m)
|
||||
}
|
||||
if len(seen) != 1 || seen[0] != http.MethodHead {
|
||||
t.Errorf("upstream saw %v, want a single HEAD", seen)
|
||||
}
|
||||
}
|
||||
+60
-26
@@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
type ProxyHandler struct {
|
||||
@@ -40,6 +42,7 @@ func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *da
|
||||
func (h *ProxyHandler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/remote/{remoteName}/*", h.handleProxy)
|
||||
r.Head("/remote/{remoteName}/*", h.handleProxyHead)
|
||||
r.Get("/local/{localName}/*", h.handleLocal)
|
||||
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
|
||||
return r
|
||||
@@ -67,31 +70,43 @@ func (h *ProxyHandler) handleDockerPing(w http.ResponseWriter, r *http.Request)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
// remoteFor resolves the remote and provider named in the request, writing the
|
||||
// error response itself when either is missing.
|
||||
func (h *ProxyHandler) remoteFor(w http.ResponseWriter, r *http.Request) (*models.Remote, provider.Provider, bool) {
|
||||
remoteName := chi.URLParam(r, "remoteName")
|
||||
path := chi.URLParam(r, "*")
|
||||
|
||||
remote, err := h.db.GetRemote(r.Context(), remoteName)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("remote %q not found", remoteName), http.StatusNotFound)
|
||||
return
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
prov, err := provider.Get(remote.PackageType)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("no provider for %q", remote.PackageType), http.StatusInternalServerError)
|
||||
return nil, nil, false
|
||||
}
|
||||
return remote, prov, true
|
||||
}
|
||||
|
||||
// serveRemote lets metadata-only remotes (e.g. github_rpm) synthesize their own
|
||||
// responses and redirect package downloads instead of proxying bytes.
|
||||
func (h *ProxyHandler) serveRemote(w http.ResponseWriter, r *http.Request, remote *models.Remote, prov provider.Provider, path string) bool {
|
||||
rs, ok := prov.(provider.RemoteServer)
|
||||
return ok && rs.ServeRemote(w, r, *remote, path, fmt.Sprintf("%s://%s", scheme(r), r.Host), h.db)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
remote, prov, ok := h.remoteFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Metadata-only remotes (e.g. github_rpm) synthesize their own responses and
|
||||
// redirect package downloads to a backend remote instead of proxying bytes.
|
||||
if rs, ok := prov.(provider.RemoteServer); ok {
|
||||
proxyBaseURL := fmt.Sprintf("%s://%s", scheme(r), r.Host)
|
||||
if rs.ServeRemote(w, r, *remote, path, proxyBaseURL, h.db) {
|
||||
return
|
||||
}
|
||||
path := chi.URLParam(r, "*")
|
||||
if h.serveRemote(w, r, remote, prov, path) {
|
||||
return
|
||||
}
|
||||
h.serveFetch(w, r, remote, prov, path)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote *models.Remote, prov provider.Provider, path string) {
|
||||
result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
|
||||
if err != nil {
|
||||
var proxyErr *proxy.ProxyError
|
||||
@@ -99,34 +114,53 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, proxyErr.Message, proxyErr.Status)
|
||||
return
|
||||
}
|
||||
slog.Error("proxy fetch failed", "remote", remoteName, "path", path, "error", err)
|
||||
slog.Error("proxy fetch failed", "remote", remote.Name, "path", path, "error", err)
|
||||
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
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("X-Artifact-Source", result.Source)
|
||||
if result.Size > 0 {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", result.Size))
|
||||
w.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.Copy(w, result.Reader)
|
||||
io.Copy(w, body)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
||||
remoteName := chi.URLParam(r, "remoteName")
|
||||
path := chi.URLParam(r, "*")
|
||||
func rewritesMutable(prov provider.Provider, path string) bool {
|
||||
_, sr := prov.(provider.ServeRewriter)
|
||||
_, rr := prov.(provider.ResponseRewriter)
|
||||
return (sr || rr) && prov.Classify(path) == provider.Mutable
|
||||
}
|
||||
|
||||
remote, err := h.db.GetRemote(r.Context(), remoteName)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("remote %q not found", remoteName), http.StatusNotFound)
|
||||
// handleProxyHead answers HEAD like GET without a body. Responses a provider
|
||||
// synthesizes or rewrites per request go through the GET path so their headers
|
||||
// match; everything else is answered from store metadata or an upstream HEAD.
|
||||
func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
||||
remote, prov, ok := h.remoteFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
prov, err := provider.Get(remote.PackageType)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("no provider for %q", remote.PackageType), http.StatusInternalServerError)
|
||||
path := chi.URLParam(r, "*")
|
||||
if h.serveRemote(w, r, remote, prov, path) {
|
||||
return
|
||||
}
|
||||
if rewritesMutable(prov, path) {
|
||||
h.serveFetch(w, r, remote, prov, path)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,7 +171,7 @@ func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, proxyErr.Message, proxyErr.Status)
|
||||
return
|
||||
}
|
||||
slog.Error("proxy head failed", "remote", remoteName, "path", path, "error", err)
|
||||
slog.Error("proxy head failed", "remote", remote.Name, "path", path, "error", err)
|
||||
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/npm"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/nuget"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
||||
)
|
||||
|
||||
func TestRoutesRemoteHead(t *testing.T) {
|
||||
r := (&ProxyHandler{}).Routes()
|
||||
for _, m := range []string{http.MethodGet, http.MethodHead} {
|
||||
if !r.Match(chi.NewRouteContext(), m, "/remote/myremote/some/path") {
|
||||
t.Errorf("%s /remote/myremote/some/path not routed", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewritesMutable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prov provider.Provider
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"npm packument", &npm.Provider{}, "left-pad", true},
|
||||
{"npm tarball", &npm.Provider{}, "left-pad/-/left-pad-1.3.0.tgz", false},
|
||||
{"nuget index", &nuget.Provider{}, "index.json", true},
|
||||
{"rpm repodata", &rpm.Provider{}, "repodata/repomd.xml", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := rewritesMutable(tc.prov, tc.path); got != tc.want {
|
||||
t.Errorf("%s: rewritesMutable = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package gems proxies a RubyGems source: mutable compact index (versions,
|
||||
// info/*, names) and legacy specs indexes, plus immutable .gem and
|
||||
// quick/Marshal.4.8 gemspec downloads.
|
||||
package gems
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"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{})
|
||||
}
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Type() models.PackageType { return models.PackageGems }
|
||||
|
||||
func (p *Provider) Classify(path string) provider.Mutability {
|
||||
if isGem(path) || isQuickSpec(path) {
|
||||
return provider.Immutable
|
||||
}
|
||||
return provider.Mutable
|
||||
}
|
||||
|
||||
func (p *Provider) ContentType(path string) string {
|
||||
path = strings.TrimLeft(path, "/")
|
||||
if path == "versions" || path == "names" || strings.HasPrefix(path, "info/") {
|
||||
return "text/plain; charset=utf-8"
|
||||
}
|
||||
if strings.HasPrefix(path, "api/v1/") && strings.HasSuffix(path, ".json") {
|
||||
return "application/json"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(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
|
||||
}
|
||||
|
||||
func isGem(path string) bool {
|
||||
path = strings.TrimLeft(path, "/")
|
||||
return strings.HasPrefix(path, "gems/") && strings.HasSuffix(path, ".gem")
|
||||
}
|
||||
|
||||
func isQuickSpec(path string) bool {
|
||||
path = strings.TrimLeft(path, "/")
|
||||
return strings.HasPrefix(path, "quick/Marshal.4.8/") && strings.HasSuffix(path, ".gemspec.rz")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package gems
|
||||
|
||||
import (
|
||||
"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
|
||||
}{
|
||||
{"gems/rack-3.1.8.gem", provider.Immutable},
|
||||
{"/gems/nokogiri-1.16.7-x86_64-linux.gem", provider.Immutable},
|
||||
{"quick/Marshal.4.8/rack-3.1.8.gemspec.rz", provider.Immutable},
|
||||
{"versions", provider.Mutable},
|
||||
{"names", provider.Mutable},
|
||||
{"info/rack", provider.Mutable},
|
||||
{"info/gems", provider.Mutable},
|
||||
{"specs.4.8.gz", provider.Mutable},
|
||||
{"latest_specs.4.8.gz", provider.Mutable},
|
||||
{"prerelease_specs.4.8.gz", provider.Mutable},
|
||||
{"api/v1/dependencies", provider.Mutable},
|
||||
{"quick/Marshal.4.8/", 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 TestContentType(t *testing.T) {
|
||||
p := &Provider{}
|
||||
tests := map[string]string{
|
||||
"versions": "text/plain; charset=utf-8",
|
||||
"info/rack": "text/plain; charset=utf-8",
|
||||
"names": "text/plain; charset=utf-8",
|
||||
"gems/rack-3.1.8.gem": "application/octet-stream",
|
||||
"specs.4.8.gz": "application/octet-stream",
|
||||
"api/v1/gems/rack.json": "application/json",
|
||||
"api/v1/dependencies.json": "application/json",
|
||||
"api/v1/dependencies": "application/octet-stream",
|
||||
}
|
||||
for path, want := range tests {
|
||||
if got := p.ContentType(path); got != want {
|
||||
t.Errorf("ContentType(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamURL(t *testing.T) {
|
||||
p := &Provider{}
|
||||
r := models.Remote{BaseURL: "https://rubygems.org/"}
|
||||
if got := p.UpstreamURL(r, "/info/rack"); got != "https://rubygems.org/info/rack" {
|
||||
t.Errorf("UpstreamURL = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
|
||||
func (p *Provider) RewritesResponse() {}
|
||||
|
||||
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
|
||||
if proxyBaseURL == "" {
|
||||
return nil, nil
|
||||
|
||||
@@ -37,6 +37,8 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
|
||||
func (p *Provider) RewritesResponse() {}
|
||||
|
||||
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
|
||||
if proxyBaseURL == "" || !json.Valid(body) {
|
||||
return nil, 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,19 @@ type RemoteServer interface {
|
||||
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
|
||||
}
|
||||
|
||||
// ResponseRewriter marks providers whose RewriteResponse changes mutable
|
||||
// bodies, so the served length is only known after a fetch.
|
||||
type ResponseRewriter interface {
|
||||
RewritesResponse()
|
||||
}
|
||||
|
||||
// RemoteMetadataStore is the persistence surface a RemoteServer needs to cache
|
||||
// and read the metadata it derives per upstream asset. *database.DB satisfies it.
|
||||
type RemoteMetadataStore interface {
|
||||
|
||||
@@ -39,6 +39,8 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
|
||||
func (p *Provider) RewritesResponse() {}
|
||||
|
||||
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
|
||||
if proxyBaseURL == "" {
|
||||
return nil, nil
|
||||
|
||||
@@ -52,6 +52,8 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
|
||||
func (p *Provider) RewritesResponse() {}
|
||||
|
||||
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
|
||||
if proxyBaseURL == "" {
|
||||
return nil, nil
|
||||
|
||||
@@ -73,6 +73,8 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||
return strings.TrimRight(remote.BaseURL, "/") + "/v1/providers/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
|
||||
func (p *Provider) RewritesResponse() {}
|
||||
|
||||
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
|
||||
if remote.ReleasesRemote == "" {
|
||||
return nil, nil
|
||||
|
||||
@@ -353,7 +353,7 @@ func (e *Engine) headUpstreamOnce(ctx context.Context, remote models.Remote, pat
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
return http.DefaultClient.Do(req)
|
||||
return clientForRemote(remote).Do(req)
|
||||
}
|
||||
|
||||
resp, err := doHead(nil)
|
||||
|
||||
@@ -216,6 +216,20 @@ func TestHead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadUpstreamUsesRemoteTimeout(t *testing.T) {
|
||||
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(3 * time.Second)
|
||||
}))
|
||||
defer slow.Close()
|
||||
r := models.Remote{Name: "slow-head", PackageType: models.PackageGeneric, BaseURL: slow.URL, UpstreamResponseHeaderTimeout: 1}
|
||||
|
||||
start := time.Now()
|
||||
_, err := (&Engine{}).headUpstreamOnce(context.Background(), r, "blob.bin", prov(t, models.PackageGeneric))
|
||||
if err == nil || time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("HEAD ignored the remote's response header timeout: err=%v after %s", err, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleOnError(t *testing.T) {
|
||||
requireStack(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -25,10 +25,12 @@ import (
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/cargo"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/deb"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/docker"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/gems"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/generic"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/helm"
|
||||
_ "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/pypi"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
||||
|
||||
@@ -17,6 +17,8 @@ const (
|
||||
PackageTerraform PackageType = "terraform"
|
||||
PackageGoProxy PackageType = "goproxy"
|
||||
PackageCargo PackageType = "cargo"
|
||||
PackageGems PackageType = "gems"
|
||||
PackageNuGet PackageType = "nuget"
|
||||
PackageGitHubRPM PackageType = "github_rpm"
|
||||
PackageGitHubDeb PackageType = "github_deb"
|
||||
PackageGitHubAlpine PackageType = "github_alpine"
|
||||
@@ -35,6 +37,8 @@ var validPackageTypes = map[PackageType]bool{
|
||||
PackageTerraform: true,
|
||||
PackageGoProxy: true,
|
||||
PackageCargo: true,
|
||||
PackageGems: true,
|
||||
PackageNuGet: true,
|
||||
PackageGitHubRPM: true,
|
||||
PackageGitHubDeb: true,
|
||||
PackageGitHubAlpine: true,
|
||||
|
||||
@@ -19,6 +19,8 @@ func TestPackageTypeValid(t *testing.T) {
|
||||
models.PackageTerraform,
|
||||
models.PackageGoProxy,
|
||||
models.PackageCargo,
|
||||
models.PackageGems,
|
||||
models.PackageNuGet,
|
||||
models.PackageGitHubRPM,
|
||||
models.PackageGitHubDeb,
|
||||
models.PackageGitHubAlpine,
|
||||
|
||||
@@ -243,6 +243,32 @@ index = "sparse+${proxy}/"`,
|
||||
},
|
||||
];
|
||||
|
||||
case 'gems':
|
||||
return [
|
||||
{
|
||||
title: 'Use this remote as the RubyGems source',
|
||||
language: 'bash',
|
||||
code: `gem sources --clear-all --add ${proxy}/
|
||||
bundle config mirror.https://rubygems.org ${proxy}`,
|
||||
},
|
||||
];
|
||||
|
||||
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':
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -18,6 +18,8 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
||||
terraform: 'blue',
|
||||
goproxy: 'green',
|
||||
cargo: 'yellow',
|
||||
gems: 'red',
|
||||
nuget: 'blue',
|
||||
};
|
||||
|
||||
export function Locals() {
|
||||
|
||||
@@ -18,6 +18,8 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
||||
terraform: 'blue',
|
||||
goproxy: 'green',
|
||||
cargo: 'yellow',
|
||||
gems: 'red',
|
||||
nuget: 'blue',
|
||||
};
|
||||
|
||||
export function Remotes() {
|
||||
|
||||
Reference in New Issue
Block a user