b0d60b3e8a
AcquireLock stored a constant value and ReleaseLock was an unconditional DEL, so a holder that outlived its TTL deleted the next holder's lock. This hits Fetch, Evict and gems push/yank. - store a random owner token with SET NX PX and return it from AcquireLock - release via a Lua compare-and-delete keyed on that token - carry the token through Fetch, Evict and Engine.Lock's release func - release the Fetch lock with an uncancelled context - make Engine.Lock's busy 503 message generic Reviewed-on: #156 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
255 lines
9.2 KiB
Go
255 lines
9.2 KiB
Go
package v1
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/md5"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.unkin.net/unkin/artifactapi/internal/cache"
|
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
)
|
|
|
|
func testGemFile(t *testing.T, name, version, platform string) []byte {
|
|
t.Helper()
|
|
meta := fmt.Sprintf(`--- !ruby/object:Gem::Specification
|
|
name: %s
|
|
version: !ruby/object:Gem::Version
|
|
version: %s
|
|
platform: %s
|
|
dependencies:
|
|
- !ruby/object:Gem::Dependency
|
|
name: rack
|
|
requirement: !ruby/object:Gem::Requirement
|
|
requirements:
|
|
- - "~>"
|
|
- !ruby/object:Gem::Version
|
|
version: '3.0'
|
|
type: :runtime
|
|
prerelease: false
|
|
rubygems_version: 3.5.22
|
|
specification_version: 4
|
|
summary: test
|
|
`, name, version, platform)
|
|
var gz bytes.Buffer
|
|
zw := gzip.NewWriter(&gz)
|
|
_, _ = zw.Write([]byte(meta))
|
|
_ = zw.Close()
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
_ = tw.WriteHeader(&tar.Header{Name: "metadata.gz", Mode: 0o444, Size: int64(gz.Len())})
|
|
_, _ = tw.Write(gz.Bytes())
|
|
_ = tw.Close()
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func sha256Hex(b []byte) string {
|
|
s := sha256.Sum256(b)
|
|
return hex.EncodeToString(s[:])
|
|
}
|
|
|
|
func TestGemsPushIndexYank(t *testing.T) {
|
|
h, srv := npmTestServer(t)
|
|
repo := npmRepo(t, h, models.PackageGems)
|
|
base := srv.URL + "/api/v1/local/" + repo + "/"
|
|
|
|
g1 := testGemFile(t, "hello_gem", "0.1.0", "ruby")
|
|
g2 := testGemFile(t, "hello_gem", "0.2.0", "ruby")
|
|
gj := testGemFile(t, "hello_gem", "0.2.0", "java")
|
|
other := testGemFile(t, "hello-gem", "1.0.0", "ruby")
|
|
for _, g := range [][]byte{g1, g2, gj, other} {
|
|
if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", string(g)); code != http.StatusOK {
|
|
t.Fatalf("push = %d: %s", code, body)
|
|
}
|
|
}
|
|
if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", string(g1)+"x"); code != http.StatusConflict {
|
|
t.Fatalf("repush with different bytes = %d, want 409", code)
|
|
}
|
|
if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", string(g1)); code != http.StatusConflict {
|
|
t.Fatalf("identical repush = %d, want 409", code)
|
|
}
|
|
if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", "not a gem"); code != http.StatusBadRequest {
|
|
t.Fatalf("garbage push = %d, want 400", code)
|
|
}
|
|
|
|
code, info := npmDo(t, http.MethodGet, base+"info/hello_gem", "")
|
|
want := "---\n0.1.0 rack:~> 3.0|checksum:" + sha256Hex(g1) + "\n0.2.0 rack:~> 3.0|checksum:" + sha256Hex(g2) +
|
|
"\n0.2.0-java rack:~> 3.0|checksum:" + sha256Hex(gj) + "\n"
|
|
if code != http.StatusOK || info != want {
|
|
t.Fatalf("info = %d\n%q\nwant\n%q", code, info, want)
|
|
}
|
|
if code, _ := npmDo(t, http.MethodHead, base+"versions", ""); code != http.StatusOK {
|
|
t.Fatalf("HEAD versions = %d; gem probes the compact index with HEAD", code)
|
|
}
|
|
_, versions := npmDo(t, http.MethodGet, base+"versions", "")
|
|
head, body, ok := strings.Cut(versions, "\n---\n")
|
|
if !ok || !strings.HasPrefix(head, "created_at: ") {
|
|
t.Fatalf("versions header = %q", versions)
|
|
}
|
|
_, otherInfo := npmDo(t, http.MethodGet, base+"info/hello-gem", "")
|
|
wantBody := fmt.Sprintf("hello-gem 1.0.0 %x\nhello_gem 0.1.0,0.2.0,0.2.0-java %x\n", md5.Sum([]byte(otherInfo)), md5.Sum([]byte(info)))
|
|
if body != wantBody {
|
|
t.Fatalf("versions body =\n%q\nwant\n%q", body, wantBody)
|
|
}
|
|
if _, names := npmDo(t, http.MethodGet, base+"names", ""); names != "---\nhello-gem\nhello_gem\n" {
|
|
t.Fatalf("names = %q", names)
|
|
}
|
|
if code, got := npmDo(t, http.MethodGet, base+"gems/hello_gem-0.2.0-java.gem", ""); code != http.StatusOK || got != string(gj) {
|
|
t.Fatalf("download = %d", code)
|
|
}
|
|
if code, _ := npmDo(t, http.MethodGet, base+"quick/Marshal.4.8/hello_gem-0.1.0.gemspec.rz", ""); code != http.StatusOK {
|
|
t.Fatalf("quick spec = %d", code)
|
|
}
|
|
if code, _ := npmDo(t, http.MethodGet, base+"info/missing", ""); code != http.StatusNotFound {
|
|
t.Fatalf("missing info = %d", code)
|
|
}
|
|
|
|
yank := func(form string) int {
|
|
req, _ := http.NewRequest(http.MethodDelete, base+"api/v1/gems/yank", strings.NewReader(form))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
if c := yank("gem_name=hello_gem&version=0.2.0&platform=java"); c != http.StatusOK {
|
|
t.Fatalf("yank = %d", c)
|
|
}
|
|
if c := yank("gem_name=hello_gem&version=0.2.0&platform=java"); c != http.StatusNotFound {
|
|
t.Fatalf("second yank = %d, want 404", c)
|
|
}
|
|
if c := yank("gem_name=../x&version=1"); c != http.StatusBadRequest {
|
|
t.Fatalf("bad yank = %d, want 400", c)
|
|
}
|
|
if _, info := npmDo(t, http.MethodGet, base+"info/hello_gem", ""); strings.Contains(info, "java") || !strings.Contains(info, "0.2.0 ") {
|
|
t.Fatalf("info after yank = %q", info)
|
|
}
|
|
if code, _ := npmDo(t, http.MethodGet, base+"gems/hello_gem-0.2.0-java.gem", ""); code != http.StatusNotFound {
|
|
t.Fatalf("yanked download = %d, want 404", code)
|
|
}
|
|
if _, v := npmDo(t, http.MethodGet, base+"versions", ""); strings.Contains(v, "java") || !strings.Contains(v, "hello_gem 0.1.0,0.2.0 ") {
|
|
t.Fatalf("versions after yank = %q", v)
|
|
}
|
|
for _, g := range []string{string(gj), string(gj) + "x"} {
|
|
if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", g); code != http.StatusConflict || !strings.Contains(body, "yanked") {
|
|
t.Fatalf("push after yank = %d %q, want 409", code, body)
|
|
}
|
|
}
|
|
if _, info := npmDo(t, http.MethodGet, base+"info/hello_gem", ""); strings.Contains(info, "java") {
|
|
t.Fatalf("info after rejected re-push = %q", info)
|
|
}
|
|
|
|
if c := yank("gem_name=hello-gem&version=1.0.0"); c != http.StatusOK {
|
|
t.Fatalf("yank last version = %d", c)
|
|
}
|
|
if code, _ := npmDo(t, http.MethodGet, base+"info/hello-gem", ""); code != http.StatusNotFound {
|
|
t.Fatalf("info for fully yanked gem = %d, want 404", code)
|
|
}
|
|
if _, names := npmDo(t, http.MethodGet, base+"names", ""); names != "---\nhello_gem\n" {
|
|
t.Fatalf("names after yanking every version = %q", names)
|
|
}
|
|
}
|
|
|
|
func TestGemsPushRejects(t *testing.T) {
|
|
h, srv := npmTestServer(t)
|
|
npmLocal := npmRepo(t, h, models.PackageNPM)
|
|
g := string(testGemFile(t, "g", "1.0.0", "ruby"))
|
|
if code, _ := npmDo(t, http.MethodPost, srv.URL+"/api/v1/local/"+npmLocal+"/api/v1/gems", g); code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("push to npm local = %d, want 405", code)
|
|
}
|
|
repo := npmRepo(t, h, models.PackageGems)
|
|
if code, _ := npmDo(t, http.MethodPost, srv.URL+"/api/v1/local/"+repo+"/other", g); code != http.StatusNotFound {
|
|
t.Fatalf("push to other path = %d, want 404", code)
|
|
}
|
|
old := gemsMaxPushBytes
|
|
gemsMaxPushBytes = 64
|
|
defer func() { gemsMaxPushBytes = old }()
|
|
if code, _ := npmDo(t, http.MethodPost, srv.URL+"/api/v1/local/"+repo+"/api/v1/gems", g); code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("oversized push = %d, want 413", code)
|
|
}
|
|
}
|
|
|
|
func TestGemsPushYankSerialised(t *testing.T) {
|
|
h, srv := npmTestServer(t)
|
|
repo := npmRepo(t, h, models.PackageGems)
|
|
base := srv.URL + "/api/v1/local/" + repo + "/"
|
|
g := string(testGemFile(t, "race", "1.0.0", "ruby"))
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 16; i++ {
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", g); code != http.StatusOK && code != http.StatusConflict {
|
|
t.Errorf("push = %d: %s", code, body)
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
if code, body := npmDo(t, http.MethodDelete, base+"api/v1/gems/yank?gem_name=race&version=1.0.0", ""); code != http.StatusOK && code != http.StatusNotFound {
|
|
t.Errorf("yank = %d: %s", code, body)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
present := func(p string) bool {
|
|
f, err := h.db.GetLocalFile(context.Background(), repo, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f != nil
|
|
}
|
|
live := []string{"_index/race/race-1.0.0", "gems/race-1.0.0.gem", "quick/Marshal.4.8/race-1.0.0.gemspec.rz"}
|
|
tomb := present("_yanked/race/race-1.0.0")
|
|
for _, p := range live {
|
|
if present(p) == tomb {
|
|
t.Errorf("tombstoned=%v but %s present=%v", tomb, p, !tomb)
|
|
}
|
|
}
|
|
_, info := npmDo(t, http.MethodGet, base+"info/race", "")
|
|
if strings.Contains(info, "1.0.0") == tomb {
|
|
t.Errorf("tombstoned=%v but info = %q", tomb, info)
|
|
}
|
|
}
|
|
|
|
func TestGemsLockTimeoutIs503(t *testing.T) {
|
|
h, srv := npmTestServer(t)
|
|
repo := npmRepo(t, h, models.PackageGems)
|
|
base := srv.URL + "/api/v1/local/" + repo + "/"
|
|
redis, err := cache.NewRedis(testRedis)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, err := redis.AcquireLock(context.Background(), repo, "_gemlock/busy-1.0.0", time.Minute)
|
|
if token == "" || err != nil {
|
|
t.Fatalf("hold lock = %q %v", token, err)
|
|
}
|
|
prev := gemsLockWait
|
|
gemsLockWait = 100 * time.Millisecond
|
|
t.Cleanup(func() { gemsLockWait = prev })
|
|
|
|
if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", string(testGemFile(t, "busy", "1.0.0", "ruby"))); code != http.StatusServiceUnavailable {
|
|
t.Fatalf("push under held lock = %d, want 503", code)
|
|
}
|
|
if code, _ := npmDo(t, http.MethodDelete, base+"api/v1/gems/yank?gem_name=busy&version=1.0.0", ""); code != http.StatusServiceUnavailable {
|
|
t.Fatalf("yank under held lock = %d, want 503", code)
|
|
}
|
|
if err := redis.ReleaseLock(context.Background(), repo, "_gemlock/busy-1.0.0", token); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", string(testGemFile(t, "busy", "1.0.0", "ruby"))); code != http.StatusOK {
|
|
t.Fatalf("push after release = %d: %s", code, body)
|
|
}
|
|
}
|