mirror of
https://github.com/regclient/regclient.git
synced 2025-04-18 22:44:00 +03:00
I feel like I need to explain, this is all to move the descriptor package. The platform package could not use the predefined errors in types because of a circular dependency from descriptor. The most appropriate way to reorg this is to move descriptor out of the type package since it was more complex than a self contained type. When doing that, type aliases were needed to avoid breaking changes to existing users. Those aliases themselves caused circular dependency loops because of the media types and errors, so those were also pulled out to separate packages. All of the old values were aliased and deprecated, and to fix the linter, those deprecations were fixed by updating the imports... everywhere. Signed-off-by: Brandon Mitchell <git@bmitch.net>
1206 lines
32 KiB
Go
1206 lines
32 KiB
Go
package regclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/opencontainers/go-digest"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"github.com/regclient/regclient/config"
|
|
"github.com/regclient/regclient/internal/reqresp"
|
|
"github.com/regclient/regclient/types/descriptor"
|
|
"github.com/regclient/regclient/types/errs"
|
|
"github.com/regclient/regclient/types/ref"
|
|
)
|
|
|
|
func TestBlobGet(t *testing.T) {
|
|
t.Parallel()
|
|
blobRepo := "/proj/repo"
|
|
privateRepo := "/proj/private"
|
|
ctx := context.Background()
|
|
// include a random blob
|
|
seed := time.Now().UTC().Unix()
|
|
t.Logf("Using seed %d", seed)
|
|
blobLen := 1024 // must be greater than 512 for retry test
|
|
d1, blob1 := reqresp.NewRandomBlob(blobLen, seed)
|
|
d2, blob2 := reqresp.NewRandomBlob(blobLen, seed+1)
|
|
bMissing := []byte("missing")
|
|
dMissing := digest.FromBytes(bMissing)
|
|
// define req/resp entries
|
|
rrs := []reqresp.ReqResp{
|
|
// head
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "HEAD for d1",
|
|
Method: "HEAD",
|
|
Path: "/v2" + blobRepo + "/blobs/" + d1.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d1.String()},
|
|
},
|
|
},
|
|
},
|
|
// get
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "GET for d1",
|
|
Method: "GET",
|
|
Path: "/v2" + blobRepo + "/blobs/" + d1.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Body: blob1,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d1.String()},
|
|
},
|
|
},
|
|
},
|
|
// missing
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "GET Missing",
|
|
Method: "GET",
|
|
Path: "/v2" + blobRepo + "/blobs/" + dMissing.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusNotFound,
|
|
},
|
|
},
|
|
// TODO: test unauthorized
|
|
// TODO: test range read
|
|
// head for d2
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "HEAD for d2",
|
|
Method: "HEAD",
|
|
Path: "/v2" + blobRepo + "/blobs/" + d2.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Headers: http.Header{
|
|
"Accept-Ranges": {"bytes"},
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d2.String()},
|
|
},
|
|
},
|
|
},
|
|
// get range
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "GET for d2, range for second part",
|
|
Method: "GET",
|
|
Path: "/v2" + blobRepo + "/blobs/" + d2.String(),
|
|
Headers: http.Header{
|
|
"Range": {fmt.Sprintf("bytes=512-%d", blobLen)},
|
|
},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Body: blob2[512:],
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen-512)},
|
|
"Content-Range": {fmt.Sprintf("bytes %d-%d/%d", 512, blobLen, blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d2.String()},
|
|
},
|
|
},
|
|
},
|
|
// get that stops early
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "GET for d2, short read",
|
|
Method: "GET",
|
|
Path: "/v2" + blobRepo + "/blobs/" + d2.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Body: blob2[0:512],
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d2.String()},
|
|
},
|
|
},
|
|
},
|
|
// forbidden
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "GET Forbidden",
|
|
Method: "GET",
|
|
Path: "/v2" + privateRepo + "/blobs/" + d1.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusForbidden,
|
|
},
|
|
},
|
|
}
|
|
rrs = append(rrs, reqresp.BaseEntries...)
|
|
// create a server
|
|
ts := httptest.NewServer(reqresp.NewHandler(t, rrs))
|
|
defer ts.Close()
|
|
// setup the regclient
|
|
tsURL, _ := url.Parse(ts.URL)
|
|
tsHost := tsURL.Host
|
|
rcHosts := []config.Host{
|
|
{
|
|
Name: tsHost,
|
|
Hostname: tsHost,
|
|
TLS: config.TLSDisabled,
|
|
ReqPerSec: 100,
|
|
},
|
|
}
|
|
log := &logrus.Logger{
|
|
Out: os.Stderr,
|
|
Formatter: new(logrus.TextFormatter),
|
|
Hooks: make(logrus.LevelHooks),
|
|
Level: logrus.WarnLevel,
|
|
}
|
|
delayInit, _ := time.ParseDuration("0.05s")
|
|
delayMax, _ := time.ParseDuration("0.10s")
|
|
rc := New(
|
|
WithConfigHost(rcHosts...),
|
|
WithLog(log),
|
|
WithRetryDelay(delayInit, delayMax),
|
|
)
|
|
// Test successful blob
|
|
t.Run("Get", func(t *testing.T) {
|
|
ref, err := ref.New(tsURL.Host + blobRepo)
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
br, err := rc.BlobGet(ctx, ref, descriptor.Descriptor{Digest: d1, Size: int64(len(blob1))})
|
|
if err != nil {
|
|
t.Fatalf("Failed running BlobGet: %v", err)
|
|
}
|
|
defer br.Close()
|
|
brBlob, err := io.ReadAll(br)
|
|
if err != nil {
|
|
t.Fatalf("Failed reading blob: %v", err)
|
|
}
|
|
if !bytes.Equal(blob1, brBlob) {
|
|
t.Errorf("Blob does not match")
|
|
}
|
|
})
|
|
|
|
// Test data
|
|
t.Run("Data", func(t *testing.T) {
|
|
ref, err := ref.New(tsURL.Host + blobRepo + "/data")
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
desc := descriptor.Descriptor{
|
|
Digest: d1,
|
|
Size: int64(len(blob1)),
|
|
Data: blob1,
|
|
}
|
|
br, err := rc.BlobGet(ctx, ref, desc)
|
|
if err != nil {
|
|
t.Fatalf("Failed running BlobGet: %v", err)
|
|
}
|
|
defer br.Close()
|
|
brBlob, err := io.ReadAll(br)
|
|
if err != nil {
|
|
t.Fatalf("Failed reading blob: %v", err)
|
|
}
|
|
if !bytes.Equal(blob1, brBlob) {
|
|
t.Errorf("Blob does not match")
|
|
}
|
|
})
|
|
|
|
t.Run("Head", func(t *testing.T) {
|
|
ref, err := ref.New(tsURL.Host + blobRepo)
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
br, err := rc.BlobHead(ctx, ref, descriptor.Descriptor{Digest: d1})
|
|
if err != nil {
|
|
t.Fatalf("Failed running BlobHead: %v", err)
|
|
}
|
|
defer br.Close()
|
|
if br.GetDescriptor().Size != int64(blobLen) {
|
|
t.Errorf("Failed comparing blob length")
|
|
}
|
|
})
|
|
|
|
t.Run("Missing", func(t *testing.T) {
|
|
ref, err := ref.New(tsURL.Host + blobRepo)
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
br, err := rc.BlobGet(ctx, ref, descriptor.Descriptor{Digest: dMissing})
|
|
if err == nil {
|
|
defer br.Close()
|
|
t.Fatalf("Unexpected success running BlobGet")
|
|
}
|
|
if !errors.Is(err, errs.ErrNotFound) {
|
|
t.Errorf("Error does not match \"ErrNotFound\": %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("Retry", func(t *testing.T) {
|
|
ref, err := ref.New(tsURL.Host + blobRepo)
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
br, err := rc.BlobGet(ctx, ref, descriptor.Descriptor{Digest: d2})
|
|
if err != nil {
|
|
t.Fatalf("Failed running BlobGet: %v", err)
|
|
}
|
|
defer br.Close()
|
|
brBlob, err := io.ReadAll(br)
|
|
if err != nil {
|
|
t.Fatalf("Failed reading blob: %v", err)
|
|
}
|
|
if !bytes.Equal(blob2, brBlob) {
|
|
t.Errorf("Blob does not match")
|
|
}
|
|
})
|
|
|
|
t.Run("Forbidden", func(t *testing.T) {
|
|
ref, err := ref.New(tsURL.Host + privateRepo)
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
br, err := rc.BlobGet(ctx, ref, descriptor.Descriptor{Digest: d1})
|
|
if err == nil {
|
|
br.Close()
|
|
t.Fatalf("Unexpected success running BlobGet")
|
|
}
|
|
if !errors.Is(err, errs.ErrHTTPUnauthorized) {
|
|
t.Errorf("Error does not match \"ErrUnauthorized\": %v", err)
|
|
}
|
|
})
|
|
|
|
}
|
|
|
|
func TestBlobPut(t *testing.T) {
|
|
t.Parallel()
|
|
blobRepo := "/proj/repo"
|
|
// privateRepo := "/proj/private"
|
|
ctx := context.Background()
|
|
// include a random blob
|
|
seed := time.Now().UTC().Unix()
|
|
t.Logf("Using seed %d", seed)
|
|
blobChunk := 512
|
|
blobLen := 1024 // must be blobChunk < blobLen <= blobChunk * 2
|
|
blobLen3 := 1000 // blob without a full final chunk
|
|
d1, blob1 := reqresp.NewRandomBlob(blobLen, seed)
|
|
d2, blob2 := reqresp.NewRandomBlob(blobLen, seed+1)
|
|
d3, blob3 := reqresp.NewRandomBlob(blobLen3, seed+2)
|
|
uuid1 := reqresp.NewRandomID(seed + 3)
|
|
uuid2 := reqresp.NewRandomID(seed + 4)
|
|
uuid3 := reqresp.NewRandomID(seed + 5)
|
|
// dMissing := digest.FromBytes([]byte("missing"))
|
|
// define req/resp entries
|
|
rrs := []reqresp.ReqResp{
|
|
// get upload location
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "POST for d1",
|
|
Method: "POST",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/",
|
|
Query: map[string][]string{
|
|
"mount": {d1.String()},
|
|
},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {uuid1},
|
|
},
|
|
},
|
|
},
|
|
// upload blob
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "PUT for d1",
|
|
Method: "PUT",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid1,
|
|
Query: map[string][]string{
|
|
"digest": {d1.String()},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", len(blob1))},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob1,
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusCreated,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {"/v2" + blobRepo + "/blobs/" + d1.String()},
|
|
"Docker-Content-Digest": {d1.String()},
|
|
},
|
|
},
|
|
},
|
|
|
|
// get upload2 location
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "POST for d2",
|
|
Method: "POST",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/",
|
|
Query: map[string][]string{
|
|
"mount": {d2.String()},
|
|
},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {uuid2},
|
|
},
|
|
},
|
|
},
|
|
// upload put for d2
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
DelOnUse: false,
|
|
Name: "PUT for patched d2",
|
|
Method: "PUT",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid2,
|
|
Query: map[string][]string{
|
|
"digest": {d2.String()},
|
|
"chunk": {"3"},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusCreated,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {"/v2" + blobRepo + "/blobs/" + d2.String()},
|
|
"Docker-Content-Digest": {d2.String()},
|
|
},
|
|
},
|
|
},
|
|
// upload patch 2 fail for d2
|
|
// {
|
|
// ReqEntry: reqresp.ReqEntry{
|
|
// DelOnUse: true,
|
|
// Name: "PATCH 2 fail for d2",
|
|
// Method: "PATCH",
|
|
// Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid2,
|
|
// Query: map[string][]string{
|
|
// "chunk": {"2"},
|
|
// },
|
|
// Headers: http.Header{
|
|
// "Content-Length": {fmt.Sprintf("%d", blobLen-blobChunk)},
|
|
// "Content-Range": {fmt.Sprintf("%d-%d", blobChunk, blobLen)},
|
|
// "Content-Type": {"application/octet-stream"},
|
|
// },
|
|
// Body: blob2[blobChunk:],
|
|
// },
|
|
// RespEntry: reqresp.RespEntry{
|
|
// Status: http.StatusGatewayTimeout,
|
|
// Headers: http.Header{
|
|
// "Content-Length": {fmt.Sprintf("%d", 0)},
|
|
// },
|
|
// },
|
|
// },
|
|
// upload patch 2 for d2
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
DelOnUse: false,
|
|
Name: "PATCH 2 for d2",
|
|
Method: "PATCH",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid2,
|
|
Query: map[string][]string{
|
|
"chunk": {"2"},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen-blobChunk)},
|
|
"Content-Range": {fmt.Sprintf("%d-%d", blobChunk, blobLen-1)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob2[blobChunk:],
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", 0)},
|
|
"Range": {fmt.Sprintf("bytes=0-%d", blobLen-1)},
|
|
"Location": {uuid2 + "?chunk=3"},
|
|
},
|
|
},
|
|
},
|
|
// upload patch 1 for d2
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
DelOnUse: false,
|
|
Name: "PATCH 1 for d2",
|
|
Method: "PATCH",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid2,
|
|
Query: map[string][]string{},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobChunk)},
|
|
"Content-Range": {fmt.Sprintf("0-%d", blobChunk-1)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob2[0:blobChunk],
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", 0)},
|
|
"Range": {fmt.Sprintf("bytes=0-%d", blobChunk-1)},
|
|
"Location": {uuid2 + "?chunk=2"},
|
|
},
|
|
},
|
|
},
|
|
// upload blob
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
DelOnUse: false,
|
|
Name: "PUT for d2",
|
|
Method: "PUT",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid2,
|
|
Query: map[string][]string{
|
|
"digest": {d2.String()},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", len(blob2))},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob2,
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusGatewayTimeout,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", 0)},
|
|
},
|
|
},
|
|
},
|
|
|
|
// get upload3 location
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "POST for d3",
|
|
Method: "POST",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/",
|
|
Query: map[string][]string{
|
|
"mount": {d3.String()},
|
|
},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {uuid3},
|
|
},
|
|
},
|
|
},
|
|
// upload put for d3
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
DelOnUse: false,
|
|
Name: "PUT for patched d3",
|
|
Method: "PUT",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid3,
|
|
Query: map[string][]string{
|
|
"digest": {d3.String()},
|
|
"chunk": {"3"},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusCreated,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {"/v2" + blobRepo + "/blobs/" + d3.String()},
|
|
"Docker-Content-Digest": {d3.String()},
|
|
},
|
|
},
|
|
},
|
|
// upload patch 2 fail for d3
|
|
// {
|
|
// ReqEntry: reqresp.ReqEntry{
|
|
// DelOnUse: true,
|
|
// Name: "PATCH 2 fail for d3",
|
|
// Method: "PATCH",
|
|
// Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid3,
|
|
// Query: map[string][]string{
|
|
// "chunk": {"2"},
|
|
// },
|
|
// Headers: http.Header{
|
|
// "Content-Length": {fmt.Sprintf("%d", blobLen3-blobChunk)},
|
|
// "Content-Range": {fmt.Sprintf("%d-%d", blobChunk, blobLen3)},
|
|
// "Content-Type": {"application/octet-stream"},
|
|
// },
|
|
// Body: blob2[blobChunk:],
|
|
// },
|
|
// RespEntry: reqresp.RespEntry{
|
|
// Status: http.StatusGatewayTimeout,
|
|
// Headers: http.Header{
|
|
// "Content-Length": {fmt.Sprintf("%d", 0)},
|
|
// },
|
|
// },
|
|
// },
|
|
// upload patch 2 for d3
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
DelOnUse: false,
|
|
Name: "PATCH 2 for d3",
|
|
Method: "PATCH",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid3,
|
|
Query: map[string][]string{
|
|
"chunk": {"2"},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen3-blobChunk)},
|
|
"Content-Range": {fmt.Sprintf("%d-%d", blobChunk, blobLen3-1)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob3[blobChunk:],
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", 0)},
|
|
"Range": {fmt.Sprintf("bytes=0-%d", blobLen3-1)},
|
|
"Location": {uuid3 + "?chunk=3"},
|
|
},
|
|
},
|
|
},
|
|
// upload patch 1 for d3
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
DelOnUse: false,
|
|
Name: "PATCH 1 for d3",
|
|
Method: "PATCH",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid3,
|
|
Query: map[string][]string{},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobChunk)},
|
|
"Content-Range": {fmt.Sprintf("0-%d", blobChunk-1)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob3[0:blobChunk],
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", 0)},
|
|
"Range": {fmt.Sprintf("bytes=0-%d", blobChunk-1)},
|
|
"Location": {uuid3 + "?chunk=2"},
|
|
},
|
|
},
|
|
},
|
|
// upload blob d3
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
DelOnUse: false,
|
|
Name: "PUT for d3",
|
|
Method: "PUT",
|
|
Path: "/v2" + blobRepo + "/blobs/uploads/" + uuid3,
|
|
Query: map[string][]string{
|
|
"digest": {d3.String()},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", len(blob3))},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob3,
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusGatewayTimeout,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", 0)},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
rrs = append(rrs, reqresp.BaseEntries...)
|
|
// create a server
|
|
ts := httptest.NewServer(reqresp.NewHandler(t, rrs))
|
|
defer ts.Close()
|
|
// setup the regclient
|
|
tsURL, _ := url.Parse(ts.URL)
|
|
tsHost := tsURL.Host
|
|
rcHosts := []config.Host{
|
|
{
|
|
Name: tsHost,
|
|
Hostname: tsHost,
|
|
TLS: config.TLSDisabled,
|
|
BlobChunk: int64(blobChunk),
|
|
BlobMax: int64(-1),
|
|
ReqPerSec: 100,
|
|
},
|
|
}
|
|
log := &logrus.Logger{
|
|
Out: os.Stderr,
|
|
Formatter: new(logrus.TextFormatter),
|
|
Hooks: make(logrus.LevelHooks),
|
|
Level: logrus.WarnLevel,
|
|
}
|
|
// use short delays for fast tests
|
|
delayInit, _ := time.ParseDuration("0.05s")
|
|
delayMax, _ := time.ParseDuration("0.10s")
|
|
rc := New(
|
|
WithConfigHost(rcHosts...),
|
|
WithLog(log),
|
|
WithRetryDelay(delayInit, delayMax),
|
|
)
|
|
|
|
t.Run("Put", func(t *testing.T) {
|
|
ref, err := ref.New(tsURL.Host + blobRepo)
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
br := bytes.NewReader(blob1)
|
|
dp, err := rc.BlobPut(ctx, ref, descriptor.Descriptor{Digest: d1, Size: int64(len(blob1))}, br)
|
|
if err != nil {
|
|
t.Fatalf("Failed running BlobPut: %v", err)
|
|
}
|
|
if dp.Digest.String() != d1.String() {
|
|
t.Errorf("Digest mismatch, expected %s, received %s", d1.String(), dp.Digest.String())
|
|
}
|
|
if dp.Size != int64(len(blob1)) {
|
|
t.Errorf("Content length mismatch, expected %d, received %d", len(blob1), dp.Size)
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("Retry", func(t *testing.T) {
|
|
ref, err := ref.New(tsURL.Host + blobRepo)
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
br := bytes.NewReader(blob2)
|
|
dp, err := rc.BlobPut(ctx, ref, descriptor.Descriptor{Digest: d2, Size: int64(len(blob2))}, br)
|
|
if err != nil {
|
|
t.Fatalf("Failed running BlobPut: %v", err)
|
|
}
|
|
if dp.Digest.String() != d2.String() {
|
|
t.Errorf("Digest mismatch, expected %s, received %s", d2.String(), dp.Digest.String())
|
|
}
|
|
if dp.Size != int64(len(blob2)) {
|
|
t.Errorf("Content length mismatch, expected %d, received %d", len(blob2), dp.Size)
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("PartialChunk", func(t *testing.T) {
|
|
ref, err := ref.New(tsURL.Host + blobRepo)
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
br := bytes.NewReader(blob3)
|
|
dp, err := rc.BlobPut(ctx, ref, descriptor.Descriptor{Digest: d3, Size: int64(len(blob3))}, br)
|
|
if err != nil {
|
|
t.Fatalf("Failed running BlobPut: %v", err)
|
|
}
|
|
if dp.Digest.String() != d3.String() {
|
|
t.Errorf("Digest mismatch, expected %s, received %s", d3.String(), dp.Digest.String())
|
|
}
|
|
if dp.Size != int64(len(blob3)) {
|
|
t.Errorf("Content length mismatch, expected %d, received %d", len(blob3), dp.Size)
|
|
}
|
|
|
|
})
|
|
}
|
|
|
|
func TestBlobCopy(t *testing.T) {
|
|
t.Parallel()
|
|
blobRepoA := "/proj/repo-a"
|
|
blobRepoB := "/proj/repo-b"
|
|
ctx := context.Background()
|
|
blobChunk := 512
|
|
// include a random blob
|
|
seed := time.Now().UTC().Unix()
|
|
t.Logf("Using seed %d", seed)
|
|
blobLen := 1024 // must be greater than 512 for retry test
|
|
d1, blob1 := reqresp.NewRandomBlob(blobLen, seed)
|
|
d2, blob2 := reqresp.NewRandomBlob(blobLen, seed+1)
|
|
d3, blob3 := reqresp.NewRandomBlob(blobLen, seed+2)
|
|
uuid1 := reqresp.NewRandomID(seed + 3)
|
|
uuid2 := reqresp.NewRandomID(seed + 4)
|
|
uuid3 := reqresp.NewRandomID(seed + 5)
|
|
|
|
// define req/resp entries
|
|
rrs := []reqresp.ReqResp{
|
|
// head
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "HEAD for repo a - d1",
|
|
Method: "HEAD",
|
|
Path: "/v2" + blobRepoA + "/blobs/" + d1.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d1.String()},
|
|
},
|
|
},
|
|
},
|
|
// head
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "HEAD for repo b - d1",
|
|
Method: "HEAD",
|
|
Path: "/v2" + blobRepoB + "/blobs/" + d1.String(),
|
|
IfState: []string{""},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusNotFound,
|
|
},
|
|
},
|
|
// get
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "GET for repo a - d1",
|
|
Method: "GET",
|
|
Path: "/v2" + blobRepoA + "/blobs/" + d1.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Body: blob1,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d1.String()},
|
|
},
|
|
},
|
|
},
|
|
// get upload location
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "POST for repo b - d1",
|
|
Method: "POST",
|
|
Path: "/v2" + blobRepoB + "/blobs/uploads/",
|
|
Query: map[string][]string{
|
|
"mount": {d1.String()},
|
|
},
|
|
IfState: []string{""},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {uuid1},
|
|
},
|
|
},
|
|
},
|
|
// upload blob
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "PUT for repo b - d1",
|
|
Method: "PUT",
|
|
Path: "/v2" + blobRepoB + "/blobs/uploads/" + uuid1,
|
|
Query: map[string][]string{
|
|
"digest": {d1.String()},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", len(blob1))},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob1,
|
|
IfState: []string{""},
|
|
SetState: "d1",
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusCreated,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {"/v2" + blobRepoB + "/blobs/" + d1.String()},
|
|
"Docker-Content-Digest": {d1.String()},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "DELETE for repo b - d1",
|
|
Method: "DELETE",
|
|
Path: "/v2" + blobRepoB + "/blobs/uploads/" + uuid1,
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
},
|
|
},
|
|
// head
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "HEAD for repo b - d1",
|
|
Method: "HEAD",
|
|
Path: "/v2" + blobRepoB + "/blobs/" + d1.String(),
|
|
IfState: []string{"d1"},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d1.String()},
|
|
},
|
|
},
|
|
},
|
|
// get
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "GET for repo b - d1",
|
|
Method: "GET",
|
|
Path: "/v2" + blobRepoB + "/blobs/" + d1.String(),
|
|
IfState: []string{"d1"},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Body: blob1,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d1.String()},
|
|
},
|
|
},
|
|
},
|
|
|
|
// head
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "HEAD for repo a - d2",
|
|
Method: "HEAD",
|
|
Path: "/v2" + blobRepoA + "/blobs/" + d2.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d2.String()},
|
|
},
|
|
},
|
|
},
|
|
// head
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "HEAD for repo b - d2",
|
|
Method: "HEAD",
|
|
Path: "/v2" + blobRepoB + "/blobs/" + d2.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusNotFound,
|
|
},
|
|
},
|
|
// get
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "GET for repo a - d2 fail",
|
|
Method: "GET",
|
|
Path: "/v2" + blobRepoA + "/blobs/" + d2.String(),
|
|
IfState: []string{"d1"},
|
|
SetState: "d2fail",
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Body: blob2[:blobChunk],
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d2.String()},
|
|
},
|
|
Fail: true,
|
|
},
|
|
},
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "GET for repo a - d2",
|
|
Method: "GET",
|
|
Path: "/v2" + blobRepoA + "/blobs/" + d2.String(),
|
|
IfState: []string{"d2fail"},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Body: blob2,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d2.String()},
|
|
},
|
|
},
|
|
},
|
|
// get upload location
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "POST for repo b - d2",
|
|
Method: "POST",
|
|
Path: "/v2" + blobRepoB + "/blobs/uploads/",
|
|
Query: map[string][]string{
|
|
"mount": {d2.String()},
|
|
},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {uuid2},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "DELETE for repo b - d2",
|
|
Method: "DELETE",
|
|
Path: "/v2" + blobRepoB + "/blobs/uploads/" + uuid2,
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
},
|
|
},
|
|
// upload blob
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "PUT for repo b - d2",
|
|
Method: "PUT",
|
|
Path: "/v2" + blobRepoB + "/blobs/uploads/" + uuid2,
|
|
Query: map[string][]string{
|
|
"digest": {d2.String()},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", len(blob2))},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob2,
|
|
SetState: "d2",
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusCreated,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {"/v2" + blobRepoB + "/blobs/" + d2.String()},
|
|
"Docker-Content-Digest": {d2.String()},
|
|
},
|
|
},
|
|
},
|
|
|
|
// head
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "HEAD for repo a - d3",
|
|
Method: "HEAD",
|
|
Path: "/v2" + blobRepoA + "/blobs/" + d3.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d3.String()},
|
|
},
|
|
},
|
|
},
|
|
// head
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "HEAD for repo b - d3",
|
|
Method: "HEAD",
|
|
Path: "/v2" + blobRepoB + "/blobs/" + d3.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusNotFound,
|
|
},
|
|
},
|
|
// get
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "GET for repo a - d3",
|
|
Method: "GET",
|
|
Path: "/v2" + blobRepoA + "/blobs/" + d3.String(),
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusOK,
|
|
Body: blob3,
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", blobLen)},
|
|
"Content-Type": {"application/octet-stream"},
|
|
"Docker-Content-Digest": {d3.String()},
|
|
},
|
|
},
|
|
},
|
|
// get upload location
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "POST for repo b - d3",
|
|
Method: "POST",
|
|
Path: "/v2" + blobRepoB + "/blobs/uploads/",
|
|
Query: map[string][]string{
|
|
"mount": {d3.String()},
|
|
},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {uuid3},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "DELETE for repo b - d3",
|
|
Method: "DELETE",
|
|
Path: "/v2" + blobRepoB + "/blobs/uploads/" + uuid3,
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusAccepted,
|
|
},
|
|
},
|
|
// upload blob
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "PUT for repo b - d3",
|
|
Method: "PUT",
|
|
Path: "/v2" + blobRepoB + "/blobs/uploads/" + uuid3,
|
|
Query: map[string][]string{
|
|
"digest": {d3.String()},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", len(blob3))},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob3,
|
|
IfState: []string{"d3fail"},
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusCreated,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {"/v2" + blobRepoB + "/blobs/" + d3.String()},
|
|
"Docker-Content-Digest": {d3.String()},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ReqEntry: reqresp.ReqEntry{
|
|
Name: "PUT for repo b - d3 fail",
|
|
Method: "PUT",
|
|
Path: "/v2" + blobRepoB + "/blobs/uploads/" + uuid3,
|
|
Query: map[string][]string{
|
|
"digest": {d3.String()},
|
|
},
|
|
Headers: http.Header{
|
|
"Content-Length": {fmt.Sprintf("%d", len(blob3))},
|
|
"Content-Type": {"application/octet-stream"},
|
|
},
|
|
Body: blob3,
|
|
SetState: "d3fail",
|
|
},
|
|
RespEntry: reqresp.RespEntry{
|
|
Status: http.StatusCreated,
|
|
Headers: http.Header{
|
|
"Content-Length": {"0"},
|
|
"Location": {"/v2" + blobRepoB + "/blobs/" + d3.String()},
|
|
"Docker-Content-Digest": {d3.String()},
|
|
},
|
|
Fail: true,
|
|
},
|
|
},
|
|
}
|
|
rrs = append(rrs, reqresp.BaseEntries...)
|
|
// create a server
|
|
ts := httptest.NewServer(reqresp.NewHandler(t, rrs))
|
|
defer ts.Close()
|
|
// setup the regclient
|
|
tsURL, _ := url.Parse(ts.URL)
|
|
tsHost := tsURL.Host
|
|
rcHosts := []config.Host{
|
|
{
|
|
Name: tsHost,
|
|
Hostname: tsHost,
|
|
TLS: config.TLSDisabled,
|
|
BlobChunk: int64(blobChunk),
|
|
BlobMax: int64(-1),
|
|
ReqPerSec: 100,
|
|
},
|
|
}
|
|
log := &logrus.Logger{
|
|
Out: os.Stderr,
|
|
Formatter: new(logrus.TextFormatter),
|
|
Hooks: make(logrus.LevelHooks),
|
|
Level: logrus.WarnLevel,
|
|
}
|
|
// use short delays for fast tests
|
|
delayInit, _ := time.ParseDuration("0.05s")
|
|
delayMax, _ := time.ParseDuration("0.10s")
|
|
rc := New(
|
|
WithConfigHost(rcHosts...),
|
|
WithLog(log),
|
|
WithRetryDelay(delayInit, delayMax),
|
|
)
|
|
|
|
refA, err := ref.New(tsURL.Host + blobRepoA)
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
refB, err := ref.New(tsURL.Host + blobRepoB)
|
|
if err != nil {
|
|
t.Fatalf("Failed creating ref: %v", err)
|
|
}
|
|
|
|
// same repo
|
|
t.Run("Copy Same Repo", func(t *testing.T) {
|
|
err = rc.BlobCopy(ctx, refA, refA, descriptor.Descriptor{Digest: d1})
|
|
if err != nil {
|
|
t.Fatalf("Failed to copy d1 from repo a to a: %v", err)
|
|
}
|
|
})
|
|
|
|
// copy blob
|
|
t.Run("Copy Diff Repo", func(t *testing.T) {
|
|
err = rc.BlobCopy(ctx, refA, refB, descriptor.Descriptor{Digest: d1})
|
|
if err != nil {
|
|
t.Fatalf("Failed to copy d1 from repo a to b: %v", err)
|
|
}
|
|
})
|
|
|
|
// blob exists
|
|
t.Run("Copy Existing Blob", func(t *testing.T) {
|
|
err = rc.BlobCopy(ctx, refA, refB, descriptor.Descriptor{Digest: d1})
|
|
if err != nil {
|
|
t.Fatalf("Failed to copy d1 from repo a to b: %v", err)
|
|
}
|
|
})
|
|
|
|
// copy fails on get, retry succeeds
|
|
t.Run("Copy Flaky Get", func(t *testing.T) {
|
|
err = rc.BlobCopy(ctx, refA, refB, descriptor.Descriptor{Digest: d2})
|
|
if err != nil {
|
|
t.Fatalf("Failed to copy d2 from repo a to b: %v", err)
|
|
}
|
|
})
|
|
|
|
// copy fails on put, retry succeeds
|
|
t.Run("Copy Flaky Put", func(t *testing.T) {
|
|
err = rc.BlobCopy(ctx, refA, refB, descriptor.Descriptor{Digest: d3})
|
|
if err != nil {
|
|
t.Fatalf("Failed to copy d3 from repo a to b: %v", err)
|
|
}
|
|
})
|
|
|
|
}
|