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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user