1
0
mirror of https://github.com/regclient/regclient.git synced 2025-04-17 11:37:11 +03:00
regclient/blob_test.go
Brandon Mitchell 1eb1ea4b34
Feat: Refactor logging to use log/slog
This updates the regclient Go library.
Existing users of logrus will continue to work using a logrus handler to slog.
Updates to the various commands will be made in a future commit.

Signed-off-by: Brandon Mitchell <git@bmitch.net>
2024-11-10 17:14:57 -05:00

1323 lines
35 KiB
Go

package regclient
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
"github.com/opencontainers/go-digest"
"github.com/regclient/regclient/config"
"github.com/regclient/regclient/internal/reqresp"
"github.com/regclient/regclient/types"
"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,
},
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
delayInit, _ := time.ParseDuration("0.05s")
delayMax, _ := time.ParseDuration("0.10s")
rc := New(
WithConfigHost(rcHosts...),
WithSlog(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),
},
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
// use short delays for fast tests
delayInit, _ := time.ParseDuration("0.05s")
delayMax, _ := time.ParseDuration("0.10s")
rc := New(
WithConfigHost(rcHosts...),
WithSlog(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)
d4, blob4 := reqresp.NewRandomBlob(blobLen, seed+3)
uuid1 := reqresp.NewRandomID(seed + 10)
uuid2 := reqresp.NewRandomID(seed + 11)
uuid3 := reqresp.NewRandomID(seed + 12)
uuid4 := reqresp.NewRandomID(seed + 13)
// 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"},
SetState: "d3",
},
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,
},
},
// head
{
ReqEntry: reqresp.ReqEntry{
Name: "HEAD for repo a - d4",
Method: "HEAD",
Path: "/v2" + blobRepoA + "/blobs/" + d4.String(),
},
RespEntry: reqresp.RespEntry{
Status: http.StatusOK,
Headers: http.Header{
"Content-Length": {fmt.Sprintf("%d", blobLen)},
"Content-Type": {"application/octet-stream"},
"Docker-Content-Digest": {d4.String()},
},
},
},
// head
{
ReqEntry: reqresp.ReqEntry{
Name: "HEAD for repo b - d4",
Method: "HEAD",
Path: "/v2" + blobRepoB + "/blobs/" + d4.String(),
IfState: []string{"", "d3"},
},
RespEntry: reqresp.RespEntry{
Status: http.StatusNotFound,
},
},
// get
{
ReqEntry: reqresp.ReqEntry{
Name: "GET for repo a - d4",
Method: "GET",
Path: "/v2" + blobRepoA + "/blobs/" + d4.String(),
},
RespEntry: reqresp.RespEntry{
Status: http.StatusOK,
Body: blob4,
Headers: http.Header{
"Content-Length": {fmt.Sprintf("%d", blobLen)},
"Content-Type": {"application/octet-stream"},
"Docker-Content-Digest": {d4.String()},
},
},
},
// get upload location
{
ReqEntry: reqresp.ReqEntry{
Name: "POST for repo b - d4",
Method: "POST",
Path: "/v2" + blobRepoB + "/blobs/uploads/",
Query: map[string][]string{
"mount": {d4.String()},
},
IfState: []string{"", "d3"},
},
RespEntry: reqresp.RespEntry{
Status: http.StatusAccepted,
Headers: http.Header{
"Content-Length": {"0"},
"Location": {uuid4},
},
},
},
// upload blob
{
ReqEntry: reqresp.ReqEntry{
Name: "PUT for repo b - d4",
Method: "PUT",
Path: "/v2" + blobRepoB + "/blobs/uploads/" + uuid4,
Query: map[string][]string{
"digest": {d4.String()},
},
Headers: http.Header{
"Content-Length": {fmt.Sprintf("%d", len(blob4))},
"Content-Type": {"application/octet-stream"},
},
Body: blob4,
IfState: []string{"", "d3"},
SetState: "d4",
},
RespEntry: reqresp.RespEntry{
Status: http.StatusCreated,
Headers: http.Header{
"Content-Length": {"0"},
"Location": {"/v2" + blobRepoB + "/blobs/" + d4.String()},
"Docker-Content-Digest": {d4.String()},
},
},
},
{
ReqEntry: reqresp.ReqEntry{
Name: "DELETE for repo b - d4",
Method: "DELETE",
Path: "/v2" + blobRepoB + "/blobs/uploads/" + uuid4,
},
RespEntry: reqresp.RespEntry{
Status: http.StatusAccepted,
},
},
}
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),
},
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
// use short delays for fast tests
delayInit, _ := time.ParseDuration("0.05s")
delayMax, _ := time.ParseDuration("0.10s")
rc := New(
WithConfigHost(rcHosts...),
WithSlog(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)
}
})
// copy with callback
t.Run("callback", func(t *testing.T) {
err = rc.BlobCopy(ctx, refA, refB, descriptor.Descriptor{Digest: d4},
BlobWithCallback(func(kind types.CallbackKind, instance string, state types.CallbackState, cur, total int64) {
if kind != types.CallbackBlob {
t.Errorf("unexpected callback kind, expected %d, received %d", types.CallbackBlob, kind)
}
if instance != d4.String() {
t.Errorf("unexpected instance, expected %s, received %s", d4.String(), instance)
}
switch state {
case types.CallbackStarted:
if cur > 0 {
t.Errorf("cur > 0 on startup, %d", cur)
}
case types.CallbackActive:
if cur > int64(blobLen) {
t.Errorf("cur > length, %d > %d", cur, blobLen)
}
case types.CallbackFinished:
case types.CallbackSkipped:
t.Errorf("blob copy skipped")
default:
t.Errorf("unexpected state, expected %d, received %d", types.CallbackActive, state)
}
}))
if err != nil {
t.Fatalf("Failed to copy d4 from repo a to b: %v", err)
}
})
}