From 3dd4acd233318967d7d87b1e29d34157cec994a2 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 17 Oct 2026 12:40:33 +1100 Subject: [PATCH 1/3] Add HEAD route for remotes matching GET headers --- e2e/head_test.go | 100 +++++++++++++++++++++++++++++++++ internal/api/v1/proxy.go | 66 +++++++++++++--------- internal/api/v1/routes_test.go | 17 ++++++ internal/proxy/engine.go | 2 +- internal/proxy/engine_test.go | 14 +++++ 5 files changed, 173 insertions(+), 26 deletions(-) create mode 100644 e2e/head_test.go create mode 100644 internal/api/v1/routes_test.go diff --git a/e2e/head_test.go b/e2e/head_test.go new file mode 100644 index 0000000..f7b9ac7 --- /dev/null +++ b/e2e/head_test.go @@ -0,0 +1,100 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "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) +} diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index 7429007..43ba060 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -16,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 { @@ -41,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 @@ -68,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 @@ -100,7 +114,7 @@ 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 } @@ -120,25 +134,27 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { 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, body) } +// 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) { - 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) + 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 _, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable { + h.serveFetch(w, r, remote, prov, path) return } @@ -149,7 +165,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 } diff --git a/internal/api/v1/routes_test.go b/internal/api/v1/routes_test.go new file mode 100644 index 0000000..4493a5c --- /dev/null +++ b/internal/api/v1/routes_test.go @@ -0,0 +1,17 @@ +package v1 + +import ( + "net/http" + "testing" + + "github.com/go-chi/chi/v5" +) + +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) + } + } +} diff --git a/internal/proxy/engine.go b/internal/proxy/engine.go index 45a1b87..cc267cc 100644 --- a/internal/proxy/engine.go +++ b/internal/proxy/engine.go @@ -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) diff --git a/internal/proxy/engine_test.go b/internal/proxy/engine_test.go index 87ce690..2374aec 100644 --- a/internal/proxy/engine_test.go +++ b/internal/proxy/engine_test.go @@ -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() -- 2.47.3 From 776990253060d2a00ea57408c89011110b730afd Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 17 Oct 2026 12:59:33 +1100 Subject: [PATCH 2/3] Fetch rewritten mutable responses on HEAD to match GET length --- e2e/head_test.go | 68 ++++++++++++++++++++++++ internal/api/v1/proxy.go | 8 ++- internal/api/v1/routes_test.go | 24 +++++++++ internal/provider/helm/helm.go | 2 + internal/provider/npm/npm.go | 2 + internal/provider/provider.go | 6 +++ internal/provider/puppet/puppet.go | 2 + internal/provider/pypi/pypi.go | 2 + internal/provider/terraform/terraform.go | 2 + 9 files changed, 115 insertions(+), 1 deletion(-) diff --git a/e2e/head_test.go b/e2e/head_test.go index f7b9ac7..9c43be3 100644 --- a/e2e/head_test.go +++ b/e2e/head_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "testing" "time" @@ -98,3 +99,70 @@ func TestProxyHeadGitHubRPMRepodata(t *testing.T) { } 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("")) + })) + 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) + } +} diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index 43ba060..39c2481 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -141,6 +141,12 @@ func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote io.Copy(w, body) } +func rewritesMutable(prov provider.Provider, path string) bool { + _, sr := prov.(provider.ServeRewriter) + _, rr := prov.(provider.ResponseRewriter) + return (sr || rr) && prov.Classify(path) == provider.Mutable +} + // 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. @@ -153,7 +159,7 @@ func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) { if h.serveRemote(w, r, remote, prov, path) { return } - if _, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable { + if rewritesMutable(prov, path) { h.serveFetch(w, r, remote, prov, path) return } diff --git a/internal/api/v1/routes_test.go b/internal/api/v1/routes_test.go index 4493a5c..e29d8cf 100644 --- a/internal/api/v1/routes_test.go +++ b/internal/api/v1/routes_test.go @@ -5,6 +5,11 @@ import ( "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) { @@ -15,3 +20,22 @@ func TestRoutesRemoteHead(t *testing.T) { } } } + +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) + } + } +} diff --git a/internal/provider/helm/helm.go b/internal/provider/helm/helm.go index d37765e..07d81a6 100644 --- a/internal/provider/helm/helm.go +++ b/internal/provider/helm/helm.go @@ -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 diff --git a/internal/provider/npm/npm.go b/internal/provider/npm/npm.go index 38cf32d..8bc098d 100644 --- a/internal/provider/npm/npm.go +++ b/internal/provider/npm/npm.go @@ -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 diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4c005f3..ca21e8c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -82,6 +82,12 @@ 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 { diff --git a/internal/provider/puppet/puppet.go b/internal/provider/puppet/puppet.go index 142a431..03bf1d8 100644 --- a/internal/provider/puppet/puppet.go +++ b/internal/provider/puppet/puppet.go @@ -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 diff --git a/internal/provider/pypi/pypi.go b/internal/provider/pypi/pypi.go index e87e9c1..373e584 100644 --- a/internal/provider/pypi/pypi.go +++ b/internal/provider/pypi/pypi.go @@ -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 diff --git a/internal/provider/terraform/terraform.go b/internal/provider/terraform/terraform.go index f29c33d..022d094 100644 --- a/internal/provider/terraform/terraform.go +++ b/internal/provider/terraform/terraform.go @@ -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 -- 2.47.3 From 9387bce560a81d2ab940cc73d60e29d570eb35b5 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 17 Oct 2026 13:19:01 +1100 Subject: [PATCH 3/3] Add gems remote type for RubyGems (#142) Ruby builds fetch gems from rubygems.org directly because artifactapi has no RubyGems remote type. - add `gems` package type proxying `https://rubygems.org`-style sources - treat `versions`, `names`, `info/*` and `*specs.4.8.gz` as mutable, `gems/*.gem` and `quick/Marshal.4.8/*.gemspec.rz` as immutable - serve `api/v1/*.json` as `application/json` - add a gem/bundler usage snippet to the UI and a gems e2e caching case Requires unkin/artifactapi#148 (HEAD route; RubyGems probes `HEAD /versions`). Reviewed-on: https://git.unkin.net/unkin/artifactapi/pulls/142 Co-authored-by: unkin-agent Co-committed-by: unkin-agent --- README.md | 1 + e2e-docker/README.md | 2 +- e2e-docker/caching_test.go | 1 + e2e-docker/fixtures/gems/mygem-1.0.0.gem | 1 + internal/provider/gems/gems.go | 62 ++++++++++++++++++++++++ internal/provider/gems/gems_test.go | 61 +++++++++++++++++++++++ internal/server/server.go | 1 + pkg/models/package_type.go | 2 + pkg/models/package_type_test.go | 1 + ui/src/components/UsageInstructions.tsx | 10 ++++ ui/src/pages/Locals.tsx | 1 + ui/src/pages/Remotes.tsx | 1 + 12 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 e2e-docker/fixtures/gems/mygem-1.0.0.gem create mode 100644 internal/provider/gems/gems.go create mode 100644 internal/provider/gems/gems_test.go diff --git a/README.md b/README.md index 5b4a4e9..45ccf41 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ 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) | diff --git a/e2e-docker/README.md b/e2e-docker/README.md index ec2ee0c..e5c8070 100644 --- a/e2e-docker/README.md +++ b/e2e-docker/README.md @@ -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, nuget) 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/` diff --git a/e2e-docker/caching_test.go b/e2e-docker/caching_test.go index 420d7f1..19bdb5a 100644 --- a/e2e-docker/caching_test.go +++ b/e2e-docker/caching_test.go @@ -30,6 +30,7 @@ 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"}, diff --git a/e2e-docker/fixtures/gems/mygem-1.0.0.gem b/e2e-docker/fixtures/gems/mygem-1.0.0.gem new file mode 100644 index 0000000..666781d --- /dev/null +++ b/e2e-docker/fixtures/gems/mygem-1.0.0.gem @@ -0,0 +1 @@ +mygem-1.0.0 e2e fixture diff --git a/internal/provider/gems/gems.go b/internal/provider/gems/gems.go new file mode 100644 index 0000000..39d324e --- /dev/null +++ b/internal/provider/gems/gems.go @@ -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") +} diff --git a/internal/provider/gems/gems_test.go b/internal/provider/gems/gems_test.go new file mode 100644 index 0000000..9707bb3 --- /dev/null +++ b/internal/provider/gems/gems_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index eb6adac..1080065 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -25,6 +25,7 @@ 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" diff --git a/pkg/models/package_type.go b/pkg/models/package_type.go index 5e310cb..803c8b2 100644 --- a/pkg/models/package_type.go +++ b/pkg/models/package_type.go @@ -17,6 +17,7 @@ const ( PackageTerraform PackageType = "terraform" PackageGoProxy PackageType = "goproxy" PackageCargo PackageType = "cargo" + PackageGems PackageType = "gems" PackageNuGet PackageType = "nuget" PackageGitHubRPM PackageType = "github_rpm" PackageGitHubDeb PackageType = "github_deb" @@ -36,6 +37,7 @@ var validPackageTypes = map[PackageType]bool{ PackageTerraform: true, PackageGoProxy: true, PackageCargo: true, + PackageGems: true, PackageNuGet: true, PackageGitHubRPM: true, PackageGitHubDeb: true, diff --git a/pkg/models/package_type_test.go b/pkg/models/package_type_test.go index dd519cf..02aa351 100644 --- a/pkg/models/package_type_test.go +++ b/pkg/models/package_type_test.go @@ -19,6 +19,7 @@ func TestPackageTypeValid(t *testing.T) { models.PackageTerraform, models.PackageGoProxy, models.PackageCargo, + models.PackageGems, models.PackageNuGet, models.PackageGitHubRPM, models.PackageGitHubDeb, diff --git a/ui/src/components/UsageInstructions.tsx b/ui/src/components/UsageInstructions.tsx index 92c1e57..9014636 100644 --- a/ui/src/components/UsageInstructions.tsx +++ b/ui/src/components/UsageInstructions.tsx @@ -243,6 +243,16 @@ 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 [ { diff --git a/ui/src/pages/Locals.tsx b/ui/src/pages/Locals.tsx index ba895ee..362b996 100644 --- a/ui/src/pages/Locals.tsx +++ b/ui/src/pages/Locals.tsx @@ -18,6 +18,7 @@ const typeColors: Record