Add gems virtual repositories (#154)

Bundler needs one source for internal and public gems, but gems virtuals have no merger, so a local gems repo and rubygems.org cannot be served together.

- merge `info/<gem>` across members (member order wins a duplicate version-platform); pass a single-member gem through verbatim
- regenerate `versions` lines and md5s for gems several members list; union `names`
- 302 `gems/*.gem` and `quick/Marshal.4.8/*` to the member owning that version, via the shared member-path validation
- return 502 on a member error; cache merges in the shared virtual cache; route HEAD on virtuals
- add `docs/gems/virtual.md` and a stock `ruby:3.3` bundler e2e-docker case

Reviewed-on: #154
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
This commit was merged in pull request #154.
This commit is contained in:
2026-10-18 02:07:52 +11:00
committed by BenVincent
parent 7dd7fc70d1
commit 18c58dc9c7
10 changed files with 633 additions and 2 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ Each type's paths, Terraform examples and local/virtual behaviour are in `docs/<
- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md)
- `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md)
- `generic`: [remote](docs/generic/remote.md)
- `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md)
- `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.md)
- `goproxy`: [remote](docs/goproxy/remote.md)
- `helm`: [remote](docs/helm/remote.md), [local](docs/helm/local.md), [virtual](docs/helm/virtual.md)
- `npm`: [remote](docs/npm/remote.md), [local](docs/npm/local.md), [virtual](docs/npm/virtual.md)
+28
View File
@@ -0,0 +1,28 @@
# `gems` virtual
A gems virtual serves one RubyGems source over local and remote gems members:
`gem install` and `bundle install` work against `/api/v1/virtual/<name>/`.
- `info/<gem>` is the union of member lines; on a duplicate version-platform
the earlier member wins. A gem only one member has is served verbatim.
- `versions` regenerates the line (and md5) of every gem several members list
from its merged `info`; other gems' lines pass through. `names` is the union.
- `gems/*.gem` and `quick/Marshal.4.8/*.gemspec.rz` 302 to the member whose
`info` line won.
- A member error fails the request with 502; a member without the gem is
skipped. Merges are cached for 60s.
```hcl
resource "artifactapi_virtual" "gems" {
name = "gems"
package_type = "gems"
members = [artifactapi_local_gems.internal.name, artifactapi_remote_gems.rubygems.name]
}
```
```ruby
# Gemfile
source "https://artifactapi.example.com/api/v1/virtual/gems/"
```
Push to the local member directly; the virtual is read-only.
+3 -1
View File
@@ -33,7 +33,9 @@ already-running stack.
`gem install`, `bundle install` + `require`, republish rejected, `gem yank`).
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
across two members; npm packument merge over two locals via a stock `node:22`
client (merged versions, install through the member redirect).
client (merged versions, install through the member redirect); gems compact
index merge over two locals via stock `ruby:3.3` bundler (member order wins,
downloads 302 to the owning member).
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
(pool = `base_url` + `mirrorlist`): round-robin distribution across both mirrors
(constant-body `mockupstreama` / `mockupstreamb`), failover past a dead primary,
+69
View File
@@ -0,0 +1,69 @@
//go:build dockere2e
package e2edocker
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// TestVirtualGemsInstall pushes to two local gems repos and drives stock
// bundler through a virtual over both: merged versions, member order winning a
// duplicate version, and downloads redirected to the owning member.
func TestVirtualGemsInstall(t *testing.T) {
network := os.Getenv("COMPOSE_NETWORK")
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
if network == "" || internal == "" {
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available on the test host")
}
id := time.Now().UnixNano()
a, b, virt := fmt.Sprintf("e2e-gems-a-%d", id), fmt.Sprintf("e2e-gems-b-%d", id), fmt.Sprintf("e2e-gems-v-%d", id)
for _, name := range []string{a, b} {
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"gems","repo_type":"local"}`, name))
defer deleteRepo(t, name)
}
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"gems","members":[%q,%q]}`, virt, a, b))
defer deleteVirtual(t, virt)
base := strings.TrimRight(internal, "/")
script := `set -euo pipefail
export GEM_HOST_API_KEY=unused
pub() { d=/w/src-$1-$2; mkdir -p $d/lib; cd $d
echo "module UnkinE2eHello; VERSION = '$1:$2'; end" > lib/unkin-e2e-hello.rb
printf 'Gem::Specification.new { |s| s.name = "unkin-e2e-hello"; s.version = "%s"; s.summary = "e2e"; s.authors = ["e2e"]; s.files = ["lib/unkin-e2e-hello.rb"] }' "$2" > g.gemspec
gem build g.gemspec >/dev/null 2>&1; gem push --host "$BASE/api/v1/local/$1" unkin-e2e-hello-$2.gem; }
pub "$A" 0.1.0
pub "$B" 0.1.0
pub "$B" 0.2.0
V="$BASE/api/v1/virtual/$V"
echo "VERSIONS $(curl -s "$V/versions" | grep '^unkin-e2e-hello ' | cut -d' ' -f2)"
mkdir -p /w/app && cd /w/app
printf 'source "%s/"\ngem "unkin-e2e-hello", "0.1.0"\n' "$V" > Gemfile
bundle install
bundle exec ruby -e 'require "unkin-e2e-hello"; puts "REQ #{UnkinE2eHello::VERSION}"'
echo "REDIRECT $(curl -s -o /dev/null -w '%{redirect_url}' "$V/gems/unkin-e2e-hello-0.2.0.gem")"`
out, err := exec.Command("docker", "run", "--rm", "--network", network,
"-e", "BASE="+base, "-e", "A="+a, "-e", "B="+b, "-e", "V="+virt,
"ruby:3.3", "bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bundler run failed: %v\n%s", err, out)
}
for _, want := range []string{
"VERSIONS 0.1.0,0.2.0",
"REQ " + a + ":0.1.0",
"REDIRECT " + base + "/api/v1/local/" + b + "/gems/unkin-e2e-hello-0.2.0.gem",
} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q in bundler output:\n%s", want, out)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
package v1
import (
"errors"
"log/slog"
"net/http"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualGems serves the merged compact index and 302s downloads to the
// member owning that version.
func (h *ProxyHandler) serveVirtualGems(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
var body []byte
var loc string
var err error
if isGemsIndexPath(path) {
body, _, err = h.virtualEngine.FetchGems(r.Context(), virt, path)
} else {
loc, err = h.virtualEngine.GemsRedirect(r.Context(), virt, path, r.URL.RawQuery, proxyBaseURL)
}
switch {
case errors.Is(err, virtual.ErrNotFound):
gemsText(w, http.StatusNotFound, "not found")
case errors.Is(err, virtual.ErrBadPath):
gemsText(w, http.StatusBadRequest, err.Error())
case err != nil:
slog.Error("virtual gems fetch failed", "virtual", virt.Name, "path", path, "error", err)
gemsText(w, http.StatusBadGateway, "bad gateway")
case loc != "":
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
http.Redirect(w, r, loc, http.StatusFound)
default:
w.Header().Set("X-Artifact-Source", "virtual")
gemsText(w, http.StatusOK, string(body))
}
}
+6
View File
@@ -52,6 +52,7 @@ func (h *ProxyHandler) Routes() chi.Router {
r.Post("/local/{localName}/*", h.handleLocalPost)
r.Delete("/local/{localName}/*", h.handleLocalDelete)
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
r.Head("/virtual/{virtualName}/*", h.handleVirtual)
return r
}
@@ -204,6 +205,11 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
return
}
if virt.PackageType == models.PackageGems {
h.serveVirtualGems(w, r, *virt, path, proxyBaseURL)
return
}
loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), *virt, path, r.URL.RawQuery, proxyBaseURL)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
+1
View File
@@ -167,6 +167,7 @@ func (s *Server) routes() chi.Router {
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
s.virtEngine.NPMLocal = proxyHandler.LocalNPMPackument
s.virtEngine.GemsLocal = proxyHandler.LocalGemsIndex
r.Mount("/api/v1", proxyHandler.Routes())
r.Mount("/v2", proxyHandler.DockerV2Routes())
+12
View File
@@ -32,6 +32,12 @@ type Engine struct {
// to the repo, or ErrNotFound.
NPMLocal func(ctx context.Context, repo, pkg string) ([]byte, error)
gemsMember func(ctx context.Context, name, path string) (*GemsMember, error)
// GemsLocal returns a local gems repo's versions, names or info/<gem>, or
// ErrNotFound.
GemsLocal func(ctx context.Context, repo, path string) ([]byte, error)
mergeTTL time.Duration
sf singleflight.Group
@@ -61,6 +67,7 @@ func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
e := &Engine{db: db, proxyEngine: proxyEngine, getRemote: db.GetRemote, mergeTTL: rpmMergeTTL}
e.rpmMember = e.fetchRPMMember
e.npmMember = e.fetchNPMMember
e.gemsMember = e.fetchGemsMember
return e
}
@@ -202,6 +209,11 @@ func (e *Engine) MemberRedirect(ctx context.Context, virt models.Virtual, path,
if !found || !slices.Contains(virt.Members, name) {
return "", false, nil
}
return e.memberLocation(ctx, name, rest, rawQuery, proxyBaseURL)
}
// memberLocation returns the URL of rest on member name's own route.
func (e *Engine) memberLocation(ctx context.Context, name, rest, rawQuery, proxyBaseURL string) (string, bool, error) {
segs, err := memberSegments(rest)
if err != nil {
return "", false, err
+294
View File
@@ -0,0 +1,294 @@
package virtual
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"time"
"git.unkin.net/unkin/artifactapi/internal/provider/gems"
"git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const (
maxGemsInfos = 1024
maxGemsCandidates = 8
)
// GemsMember is one member's compact index document; Body is nil when the
// member does not have it.
type GemsMember struct {
RemoteName string
Body []byte
}
// GemsDoc is a merged compact index document. Owners maps each /info
// version[-platform] to the member serving it.
type GemsDoc struct {
Body []byte
Owners map[string]string
}
func (d *GemsDoc) index() []byte { return d.Body }
func (d *GemsDoc) files() map[string][]byte { return nil }
// MergeGemsInfo unions /info lines across members; the first member wins a
// duplicate version-platform. A gem only one member has is passed through
// verbatim so its md5 matches that member's /versions. ErrNotFound means no
// member has the gem.
func MergeGemsInfo(members []GemsMember) (*GemsDoc, error) {
var have []GemsMember
for _, m := range members {
if m.Body != nil {
have = append(have, m)
}
}
if len(have) == 0 {
return nil, ErrNotFound
}
d := &GemsDoc{Owners: map[string]string{}}
var lines []string
for _, m := range have {
for _, l := range gems.InfoLines(m.Body) {
k := gems.InfoKey(l)
if _, dup := d.Owners[k]; dup {
continue
}
d.Owners[k] = m.RemoteName
lines = append(lines, l)
}
}
if len(have) == 1 {
d.Body = have[0].Body
} else {
d.Body = gems.InfoBody(lines)
}
return d, nil
}
// versionsLines returns the entry lines of a /versions document.
func versionsLines(m GemsMember) ([]byte, error) {
if m.Body == nil {
return nil, fmt.Errorf("member %q has no versions", m.RemoteName)
}
if bytes.HasPrefix(m.Body, []byte("---\n")) {
return m.Body[4:], nil
}
_, rest, ok := bytes.Cut(m.Body, []byte("\n---\n"))
if !ok {
return nil, fmt.Errorf("member %q versions has no --- separator", m.RemoteName)
}
return rest, nil
}
func gemName(line []byte) string {
n, _, _ := bytes.Cut(line, []byte(" "))
return string(bytes.TrimSpace(n))
}
// MergeGemsVersions passes through the lines of gems only one member lists
// and writes one regenerated line, from the merged /info, per gem that
// several members list.
func MergeGemsVersions(members []GemsMember, info func(name string) (*GemsDoc, error), at time.Time) ([]byte, error) {
bodies := make([][]byte, len(members))
owners := map[string]int{}
for i, m := range members {
rest, err := versionsLines(m)
if err != nil {
return nil, err
}
bodies[i] = rest
seen := map[string]bool{}
for line := range bytes.Lines(rest) {
if n := gemName(line); n != "" && !seen[n] {
seen[n] = true
owners[n]++
}
}
}
var b bytes.Buffer
b.WriteString(gems.VersionsHeader(at))
var shared []string
for _, rest := range bodies {
for line := range bytes.Lines(rest) {
n := gemName(line)
if n == "" {
continue
}
if owners[n] > 1 {
if !slices.Contains(shared, n) {
shared = append(shared, n)
}
continue
}
b.Write(bytes.TrimRight(line, "\n"))
b.WriteByte('\n')
}
}
for _, n := range shared {
d, err := info(n)
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return nil, err
}
b.WriteString(gems.VersionsLine(n, d.Body) + "\n")
}
return b.Bytes(), nil
}
// MergeGemsNames unions member /names documents, sorted.
func MergeGemsNames(members []GemsMember) ([]byte, error) {
set := map[string]bool{}
for _, m := range members {
rest, err := versionsLines(m)
if err != nil {
return nil, err
}
for line := range bytes.Lines(rest) {
if n := gemName(line); n != "" {
set[n] = true
}
}
}
names := make([]string, 0, len(set))
for n := range set {
names = append(names, n)
}
slices.Sort(names)
return []byte("---\n" + strings.Join(names, "\n") + "\n"), nil
}
// FetchGems serves a gems virtual's merged versions, names or info/<gem>.
func (e *Engine) FetchGems(ctx context.Context, virt models.Virtual, path string) ([]byte, string, error) {
var d *GemsDoc
var err error
switch {
case strings.HasPrefix(path, "info/"):
name := strings.TrimPrefix(path, "info/")
if !gems.NameRe.MatchString(name) {
return nil, "", ErrNotFound
}
d, err = e.gemsInfo(ctx, virt, name)
case path == "versions":
d, err = e.gemsMerged(ctx, virt, path, func(ctx context.Context, ms []GemsMember) ([]byte, error) {
return MergeGemsVersions(ms, func(name string) (*GemsDoc, error) { return e.gemsInfo(ctx, virt, name) }, time.Now())
})
case path == "names":
d, err = e.gemsMerged(ctx, virt, path, func(_ context.Context, ms []GemsMember) ([]byte, error) {
return MergeGemsNames(ms)
})
default:
return nil, "", ErrNotFound
}
if err != nil {
return nil, "", err
}
return d.Body, "text/plain; charset=utf-8", nil
}
func (e *Engine) gemsMerged(ctx context.Context, virt models.Virtual, path string, merge func(context.Context, []GemsMember) ([]byte, error)) (*GemsDoc, error) {
repo, err := e.merged(ctx, "gems\x00"+virt.Name+"\x00"+path, func(ctx context.Context) (mergedRepo, error) {
members, err := fetchMembers(virt, func(name string) (*GemsMember, error) { return e.gemsMember(ctx, name, path) })
if err != nil {
return nil, err
}
body, err := merge(ctx, members)
if err != nil {
return nil, err
}
return &GemsDoc{Body: body}, nil
})
if err != nil {
return nil, err
}
return repo.(*GemsDoc), nil
}
func (e *Engine) gemsInfo(ctx context.Context, virt models.Virtual, name string) (*GemsDoc, error) {
prefix := "gems\x00" + virt.Name + "\x00info/"
inserted := false
repo, err := e.merged(ctx, prefix+name, func(ctx context.Context) (mergedRepo, error) {
inserted = true
members, err := fetchMembers(virt, func(m string) (*GemsMember, error) { return e.gemsMember(ctx, m, "info/"+name) })
if err != nil {
return nil, err
}
return MergeGemsInfo(members)
})
if err != nil {
return nil, err
}
if inserted {
e.evictOldest(prefix, maxGemsInfos)
}
return repo.(*GemsDoc), nil
}
// GemsRedirect maps a gems virtual download (gems/<full>.gem or
// quick/Marshal.4.8/<full>.gemspec.rz) to the member whose /info line won the
// merge for that version-platform.
func (e *Engine) GemsRedirect(ctx context.Context, virt models.Virtual, path, rawQuery, proxyBaseURL string) (string, error) {
full, ok := strings.CutPrefix(path, "gems/")
if ok {
full, ok = strings.CutSuffix(full, ".gem")
} else if full, ok = strings.CutPrefix(path, "quick/Marshal.4.8/"); ok {
full, ok = strings.CutSuffix(full, ".gemspec.rz")
}
if !ok || strings.Contains(full, "/") {
return "", ErrNotFound
}
tried := 0
for i := 1; i < len(full)-1 && tried < maxGemsCandidates; i++ {
if full[i] != '-' || full[i+1] < '0' || full[i+1] > '9' || !gems.NameRe.MatchString(full[:i]) {
continue
}
tried++
d, err := e.gemsInfo(ctx, virt, full[:i])
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return "", err
}
if owner, ok := d.Owners[full[i+1:]]; ok {
loc, ok, err := e.memberLocation(ctx, owner, path, rawQuery, proxyBaseURL)
if err == nil && !ok {
err = ErrNotFound
}
return loc, err
}
}
return "", ErrNotFound
}
// fetchGemsMember returns a nil Body when the member lacks the document.
func (e *Engine) fetchGemsMember(ctx context.Context, name, path string) (*GemsMember, error) {
remote, err := e.getRemote(ctx, name)
if err != nil {
return nil, fmt.Errorf("remote %q: %w", name, err)
}
m := &GemsMember{RemoteName: name}
if remote.RepoType == models.RepoTypeLocal {
if e.GemsLocal == nil {
return nil, errors.New("local gems indexes are not wired")
}
m.Body, err = e.GemsLocal(ctx, name, path)
} else {
m.Body, err = e.fetchMemberPath(ctx, *remote, path)
}
var pe *proxy.ProxyError
if errors.Is(err, ErrNotFound) || errors.As(err, &pe) && pe.Status == http.StatusNotFound {
return &GemsMember{RemoteName: name}, nil
}
if err != nil {
return nil, err
}
return m, nil
}
+180
View File
@@ -0,0 +1,180 @@
package virtual
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"git.unkin.net/unkin/artifactapi/internal/provider/gems"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestMergeGemsInfoMemberOrderWins(t *testing.T) {
local := GemsMember{RemoteName: "internal", Body: []byte("---\n1.0.0 |checksum:aa\n1.0.0-java |checksum:ab\n")}
remote := GemsMember{RemoteName: "rubygems", Body: []byte("---\n0.9.0 |checksum:99\n1.0.0 |checksum:bb\n2.0.0 rack:>= 0|checksum:cc,ruby:>= 3.0\n")}
d, err := MergeGemsInfo([]GemsMember{local, {RemoteName: "empty"}, remote})
if err != nil {
t.Fatal(err)
}
want := "---\n1.0.0 |checksum:aa\n1.0.0-java |checksum:ab\n0.9.0 |checksum:99\n2.0.0 rack:>= 0|checksum:cc,ruby:>= 3.0\n"
if string(d.Body) != want {
t.Fatalf("merged info =\n%q\nwant\n%q", d.Body, want)
}
for k, owner := range map[string]string{"1.0.0": "internal", "1.0.0-java": "internal", "0.9.0": "rubygems", "2.0.0": "rubygems"} {
if d.Owners[k] != owner {
t.Errorf("owner of %s = %q, want %q", k, d.Owners[k], owner)
}
}
odd := GemsMember{RemoteName: "rubygems", Body: []byte("---\n1.0.0 |checksum:bb\n\n")}
if d, _ := MergeGemsInfo([]GemsMember{{RemoteName: "internal"}, odd}); string(d.Body) != string(odd.Body) {
t.Fatalf("a gem only one member has must pass through verbatim, got %q", d.Body)
}
if _, err := MergeGemsInfo([]GemsMember{{RemoteName: "a"}}); !errors.Is(err, ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func TestMergeGemsVersions(t *testing.T) {
local := GemsMember{RemoteName: "internal", Body: []byte("created_at: 2026-10-18T00:00:00Z\n---\nhello 0.1.0 11\nrack 9.0.0 22\n")}
remote := GemsMember{RemoteName: "rubygems", Body: []byte("created_at: 2024-04-01T00:00:05Z\n---\n" +
"rack 1.0.0,2.0.0 33\nrails 7.0.0 44\nrack -1.0.0,3.0.0 55\n")}
merged := &GemsDoc{Body: []byte("---\n9.0.0 |checksum:x\n2.0.0 |checksum:y\n3.0.0 |checksum:z\n")}
var asked []string
at := time.Date(2026, 10, 18, 1, 2, 3, 0, time.UTC)
body, err := MergeGemsVersions([]GemsMember{local, remote}, func(name string) (*GemsDoc, error) {
asked = append(asked, name)
return merged, nil
}, at)
if err != nil {
t.Fatal(err)
}
want := "created_at: 2026-10-18T01:02:03Z\n---\nhello 0.1.0 11\nrails 7.0.0 44\nrack 9.0.0,2.0.0,3.0.0 " + gems.MD5Hex(merged.Body) + "\n"
if string(body) != want {
t.Fatalf("versions =\n%q\nwant\n%q", body, want)
}
if len(asked) != 1 || asked[0] != "rack" {
t.Fatalf("merged info fetched for %v, want only the shared gem", asked)
}
if _, err := MergeGemsVersions([]GemsMember{{RemoteName: "x", Body: []byte("no separator")}}, nil, at); err == nil {
t.Fatal("malformed member versions must fail")
}
if _, err := MergeGemsVersions([]GemsMember{{RemoteName: "x"}}, nil, at); err == nil {
t.Fatal("a member without versions must fail")
}
}
func TestMergeGemsNames(t *testing.T) {
body, err := MergeGemsNames([]GemsMember{{RemoteName: "a", Body: []byte("---\nzeta\nalpha\n")}, {RemoteName: "b", Body: []byte("---\nalpha\nmid\n")}})
if err != nil || string(body) != "---\nalpha\nmid\nzeta\n" {
t.Fatalf("names = %q %v", body, err)
}
}
func gemsEngine(fetch func(name, path string) (*GemsMember, error)) *Engine {
return &Engine{
mergeTTL: time.Minute,
getRemote: func(_ context.Context, name string) (*models.Remote, error) {
rt := models.RepoTypeRemote
if name == "internal" {
rt = models.RepoTypeLocal
}
return &models.Remote{Name: name, RepoType: rt}, nil
},
gemsMember: func(_ context.Context, name, path string) (*GemsMember, error) { return fetch(name, path) },
}
}
func gemsVirt(members ...string) models.Virtual {
return models.Virtual{Name: "gems", PackageType: models.PackageGems, Members: members}
}
func TestGemsRedirectToOwner(t *testing.T) {
var mu sync.Mutex
asked := map[string]int{}
e := gemsEngine(func(name, path string) (*GemsMember, error) {
mu.Lock()
asked[name+" "+path]++
mu.Unlock()
switch name + " " + path {
case "internal info/foo-2fa":
return &GemsMember{RemoteName: name, Body: []byte("---\n1.0.0 |checksum:a\n")}, nil
case "rubygems info/foo-2fa":
return &GemsMember{RemoteName: name, Body: []byte("---\n1.0.0 |checksum:b\n1.1.0-x86_64-linux |checksum:c\n")}, nil
case "rubygems info/foo":
return &GemsMember{RemoteName: name, Body: []byte("---\n2.0.0 |checksum:d\n")}, nil
}
return &GemsMember{RemoteName: name}, nil
})
virt := gemsVirt("internal", "rubygems")
for path, want := range map[string]string{
"gems/foo-2fa-1.0.0.gem": "http://host/api/v1/local/internal/gems/foo-2fa-1.0.0.gem",
"gems/foo-2fa-1.1.0-x86_64-linux.gem": "http://host/api/v1/remote/rubygems/gems/foo-2fa-1.1.0-x86_64-linux.gem",
"quick/Marshal.4.8/foo-2fa-1.0.0.gemspec.rz": "http://host/api/v1/local/internal/quick/Marshal.4.8/foo-2fa-1.0.0.gemspec.rz",
"quick/Marshal.4.8/foo-2.0.0.gemspec.rz": "http://host/api/v1/remote/rubygems/quick/Marshal.4.8/foo-2.0.0.gemspec.rz",
} {
loc, err := e.GemsRedirect(context.Background(), virt, path, "", "http://host")
if err != nil || loc != want {
t.Errorf("%s -> %q %v, want %q", path, loc, err, want)
}
}
for _, path := range []string{"gems/foo-2fa-9.9.9.gem", "gems/nodash.gem", "gems/x/../foo-2.0.0.gem", "other/foo-2.0.0.gem", "specs.4.8.gz"} {
if _, err := e.GemsRedirect(context.Background(), virt, path, "", "http://host"); !errors.Is(err, ErrNotFound) {
t.Errorf("%s: want ErrNotFound, got %v", path, err)
}
}
if asked["internal info/foo-2fa"] != 1 {
t.Fatalf("merged info must be cached across redirects, internal asked %d times", asked["internal info/foo-2fa"])
}
}
func TestFetchGemsFailsClosed(t *testing.T) {
e := gemsEngine(func(name, path string) (*GemsMember, error) {
if name == "rubygems" {
return nil, errors.New("upstream down")
}
return &GemsMember{RemoteName: name, Body: []byte("created_at: x\n---\nhello 0.1.0 11\n")}, nil
})
for _, path := range []string{"versions", "names", "info/hello"} {
if _, _, err := e.FetchGems(context.Background(), gemsVirt("internal", "rubygems"), path); err == nil || errors.Is(err, ErrNotFound) {
t.Errorf("%s: want member error, got %v", path, err)
}
}
if _, err := e.GemsRedirect(context.Background(), gemsVirt("internal", "rubygems"), "gems/hello-0.1.0.gem", "", "http://h"); err == nil || errors.Is(err, ErrNotFound) {
t.Errorf("redirect: want member error, got %v", err)
}
}
func TestFetchGemsVersionsUsesMergedInfo(t *testing.T) {
e := gemsEngine(func(name, path string) (*GemsMember, error) {
docs := map[string]string{
"internal versions": "created_at: x\n---\nrack 9.0.0 11\n",
"rubygems versions": "created_at: y\n---\nrack 3.0.0 22\nrails 7.0.0 33\n",
"internal info/rack": "---\n9.0.0 |checksum:a\n",
"rubygems info/rack": "---\n3.0.0 |checksum:b\n",
}
if d, ok := docs[name+" "+path]; ok {
return &GemsMember{RemoteName: name, Body: []byte(d)}, nil
}
return &GemsMember{RemoteName: name}, nil
})
virt := gemsVirt("internal", "rubygems")
info, ct, err := e.FetchGems(context.Background(), virt, "info/rack")
if err != nil || !strings.HasPrefix(ct, "text/plain") {
t.Fatalf("info: %v %q", err, ct)
}
versions, _, err := e.FetchGems(context.Background(), virt, "versions")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(versions), "\nrails 7.0.0 33\nrack 9.0.0,3.0.0 "+gems.MD5Hex(info)+"\n") {
t.Fatalf("versions = %q", versions)
}
if _, _, err := e.FetchGems(context.Background(), virt, "info/../x"); !errors.Is(err, ErrNotFound) {
t.Fatalf("bad name: %v", err)
}
}