forked from TrueCloudLab/distribution
f55a6552b0
Fix runaway allocation on /v2/_catalog
3156 lines
100 KiB
Go
3156 lines
100 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/distribution/distribution/v3"
|
|
"github.com/distribution/distribution/v3/configuration"
|
|
"github.com/distribution/distribution/v3/manifest"
|
|
"github.com/distribution/distribution/v3/manifest/manifestlist"
|
|
"github.com/distribution/distribution/v3/manifest/schema1" //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
"github.com/distribution/distribution/v3/manifest/schema2"
|
|
"github.com/distribution/distribution/v3/reference"
|
|
"github.com/distribution/distribution/v3/registry/api/errcode"
|
|
v2 "github.com/distribution/distribution/v3/registry/api/v2"
|
|
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
|
|
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
|
|
_ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory"
|
|
"github.com/distribution/distribution/v3/testutil"
|
|
"github.com/docker/libtrust"
|
|
"github.com/gorilla/handlers"
|
|
"github.com/opencontainers/go-digest"
|
|
)
|
|
|
|
var headerConfig = http.Header{
|
|
"X-Content-Type-Options": []string{"nosniff"},
|
|
}
|
|
|
|
const (
|
|
// digestSha256EmptyTar is the canonical sha256 digest of empty data
|
|
digestSha256EmptyTar = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
)
|
|
|
|
// TestCheckAPI hits the base endpoint (/v2/) ensures we return the specified
|
|
// 200 OK response.
|
|
func TestCheckAPI(t *testing.T) {
|
|
env := newTestEnv(t, false)
|
|
defer env.Shutdown()
|
|
baseURL, err := env.builder.BuildBaseURL()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building base url: %v", err)
|
|
}
|
|
|
|
resp, err := http.Get(baseURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing api base check", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Content-Type": []string{"application/json"},
|
|
"Content-Length": []string{"2"},
|
|
})
|
|
|
|
p, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error reading response body: %v", err)
|
|
}
|
|
|
|
if string(p) != "{}" {
|
|
t.Fatalf("unexpected response body: %v", string(p))
|
|
}
|
|
}
|
|
|
|
// TestCatalogAPI tests the /v2/_catalog endpoint
|
|
func TestCatalogAPI(t *testing.T) {
|
|
env := newTestEnv(t, false)
|
|
defer env.Shutdown()
|
|
|
|
maxEntries := env.config.Catalog.MaxEntries
|
|
allCatalog := []string{
|
|
"foo/aaaa", "foo/bbbb", "foo/cccc", "foo/dddd", "foo/eeee", "foo/ffff",
|
|
}
|
|
|
|
chunkLen := maxEntries - 1
|
|
|
|
catalogURL, err := env.builder.BuildCatalogURL()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
// -----------------------------------
|
|
// Case No. 1: Empty catalog
|
|
resp, err := http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
|
|
|
|
var ctlg struct {
|
|
Repositories []string `json:"repositories"`
|
|
}
|
|
|
|
dec := json.NewDecoder(resp.Body)
|
|
if err := dec.Decode(&ctlg); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
// No images pushed = no image returned
|
|
if len(ctlg.Repositories) != 0 {
|
|
t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", 0, len(ctlg.Repositories))
|
|
}
|
|
|
|
// No pagination should be returned
|
|
if resp.Header.Get("Link") != "" {
|
|
t.Fatalf("repositories has more data when none expected")
|
|
}
|
|
|
|
for _, image := range allCatalog {
|
|
createRepository(env, t, image, "sometag")
|
|
}
|
|
|
|
// -----------------------------------
|
|
// Case No. 2: Catalog populated & n is not provided nil (n internally will be min(100, maxEntries))
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
|
|
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err = dec.Decode(&ctlg); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
// it must match max entries
|
|
if len(ctlg.Repositories) != maxEntries {
|
|
t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", maxEntries, len(ctlg.Repositories))
|
|
}
|
|
|
|
// it must return the first maxEntries entries from the catalog
|
|
for _, image := range allCatalog[:maxEntries] {
|
|
if !contains(ctlg.Repositories, image) {
|
|
t.Fatalf("didn't find our repository '%s' in the catalog", image)
|
|
}
|
|
}
|
|
|
|
// fail if there's no pagination
|
|
link := resp.Header.Get("Link")
|
|
if link == "" {
|
|
t.Fatalf("repositories has less data than expected")
|
|
}
|
|
// -----------------------------------
|
|
// Case No. 2.1: Second page (n internally will be min(100, maxEntries))
|
|
|
|
// build pagination link
|
|
values := checkLink(t, link, maxEntries, ctlg.Repositories[len(ctlg.Repositories)-1])
|
|
|
|
catalogURL, err = env.builder.BuildCatalogURL(values)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
|
|
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err = dec.Decode(&ctlg); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
expectedRemainder := len(allCatalog) - maxEntries
|
|
|
|
if len(ctlg.Repositories) != expectedRemainder {
|
|
t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories))
|
|
}
|
|
|
|
// -----------------------------------
|
|
// Case No. 3: request n = maxentries
|
|
values = url.Values{
|
|
"last": []string{""},
|
|
"n": []string{strconv.Itoa(maxEntries)},
|
|
}
|
|
|
|
catalogURL, err = env.builder.BuildCatalogURL(values)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
|
|
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err = dec.Decode(&ctlg); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
if len(ctlg.Repositories) != maxEntries {
|
|
t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", maxEntries, len(ctlg.Repositories))
|
|
}
|
|
|
|
// fail if there's no pagination
|
|
link = resp.Header.Get("Link")
|
|
if link == "" {
|
|
t.Fatalf("repositories has less data than expected")
|
|
}
|
|
|
|
// -----------------------------------
|
|
// Case No. 3.1: Second (last) page
|
|
|
|
// build pagination link
|
|
values = checkLink(t, link, maxEntries, ctlg.Repositories[len(ctlg.Repositories)-1])
|
|
|
|
catalogURL, err = env.builder.BuildCatalogURL(values)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
|
|
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err = dec.Decode(&ctlg); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
expectedRemainder = len(allCatalog) - maxEntries
|
|
|
|
if len(ctlg.Repositories) != expectedRemainder {
|
|
t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories))
|
|
}
|
|
|
|
// -----------------------------------
|
|
// Case No. 4: request n < maxentries
|
|
|
|
values = url.Values{
|
|
"n": []string{strconv.Itoa(chunkLen)},
|
|
}
|
|
|
|
catalogURL, err = env.builder.BuildCatalogURL(values)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
|
|
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err = dec.Decode(&ctlg); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
// returns the requested amount
|
|
if len(ctlg.Repositories) != chunkLen {
|
|
t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories))
|
|
}
|
|
|
|
// fail if there's no pagination
|
|
link = resp.Header.Get("Link")
|
|
if link == "" {
|
|
t.Fatalf("repositories has less data than expected")
|
|
}
|
|
|
|
// -----------------------------------
|
|
// Case No. 4.1: request n < maxentries (second page)
|
|
|
|
// build pagination link
|
|
values = checkLink(t, link, chunkLen, ctlg.Repositories[len(ctlg.Repositories)-1])
|
|
|
|
catalogURL, err = env.builder.BuildCatalogURL(values)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
|
|
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err = dec.Decode(&ctlg); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
expectedRemainder = len(allCatalog) - chunkLen
|
|
|
|
if len(ctlg.Repositories) != expectedRemainder {
|
|
t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories))
|
|
}
|
|
|
|
// -----------------------------------
|
|
// Case No. 5: request n > maxentries
|
|
|
|
values = url.Values{
|
|
"n": []string{strconv.Itoa(maxEntries + 10)},
|
|
}
|
|
|
|
catalogURL, err = env.builder.BuildCatalogURL(values)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusBadRequest)
|
|
checkBodyHasErrorCodes(t, "invalid number of results requested", resp, v2.ErrorCodePaginationNumberInvalid)
|
|
|
|
// -----------------------------------
|
|
// Case No. 6: request n > maxentries but <= total catalog
|
|
|
|
values = url.Values{
|
|
"n": []string{strconv.Itoa(len(allCatalog))},
|
|
}
|
|
|
|
catalogURL, err = env.builder.BuildCatalogURL(values)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusBadRequest)
|
|
checkBodyHasErrorCodes(t, "invalid number of results requested", resp, v2.ErrorCodePaginationNumberInvalid)
|
|
|
|
// -----------------------------------
|
|
// Case No. 7: n = 0
|
|
values = url.Values{
|
|
"n": []string{"0"},
|
|
}
|
|
|
|
catalogURL, err = env.builder.BuildCatalogURL(values)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
|
|
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err = dec.Decode(&ctlg); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
// it must match max entries
|
|
if len(ctlg.Repositories) != 0 {
|
|
t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", 0, len(ctlg.Repositories))
|
|
}
|
|
|
|
// -----------------------------------
|
|
// Case No. 8: n = -1
|
|
values = url.Values{
|
|
"n": []string{"-1"},
|
|
}
|
|
|
|
catalogURL, err = env.builder.BuildCatalogURL(values)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusBadRequest)
|
|
checkBodyHasErrorCodes(t, "invalid number of results requested", resp, v2.ErrorCodePaginationNumberInvalid)
|
|
|
|
// -----------------------------------
|
|
// Case No. 9: n = 5, max = 5, total catalog = 4
|
|
values = url.Values{
|
|
"n": []string{strconv.Itoa(maxEntries)},
|
|
}
|
|
|
|
envWithLessImages := newTestEnv(t, false)
|
|
for _, image := range allCatalog[0:(maxEntries - 1)] {
|
|
createRepository(envWithLessImages, t, image, "sometag")
|
|
}
|
|
|
|
catalogURL, err = envWithLessImages.builder.BuildCatalogURL(values)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building catalog url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(catalogURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
|
|
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err = dec.Decode(&ctlg); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
// it must match max entries
|
|
if len(ctlg.Repositories) != maxEntries-1 {
|
|
t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", maxEntries-1, len(ctlg.Repositories))
|
|
}
|
|
}
|
|
|
|
// TestTagsAPI tests the /v2/<name>/tags/list endpoint
|
|
func TestTagsAPI(t *testing.T) {
|
|
env := newTestEnv(t, false)
|
|
defer env.Shutdown()
|
|
|
|
imageName, err := reference.WithName("test")
|
|
if err != nil {
|
|
t.Fatalf("unable to parse reference: %v", err)
|
|
}
|
|
|
|
tags := []string{
|
|
"2j2ar",
|
|
"asj9e",
|
|
"jyi7b",
|
|
"kb0j5",
|
|
"sb71y",
|
|
}
|
|
|
|
for _, tag := range tags {
|
|
createRepository(env, t, imageName.Name(), tag)
|
|
}
|
|
|
|
tt := []struct {
|
|
name string
|
|
queryParams url.Values
|
|
expectedStatusCode int
|
|
expectedBody tagsAPIResponse
|
|
expectedBodyErr *errcode.ErrorCode
|
|
expectedLinkHeader string
|
|
}{
|
|
{
|
|
name: "no query parameters",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: tags},
|
|
},
|
|
{
|
|
name: "empty last query parameter",
|
|
queryParams: url.Values{"last": []string{""}},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: tags},
|
|
},
|
|
{
|
|
name: "empty n query parameter",
|
|
queryParams: url.Values{"n": []string{""}},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: tags},
|
|
},
|
|
{
|
|
name: "empty last and n query parameters",
|
|
queryParams: url.Values{"last": []string{""}, "n": []string{""}},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: tags},
|
|
},
|
|
{
|
|
name: "negative n query parameter",
|
|
queryParams: url.Values{"n": []string{"-1"}},
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedBodyErr: &v2.ErrorCodePaginationNumberInvalid,
|
|
},
|
|
{
|
|
name: "non integer n query parameter",
|
|
queryParams: url.Values{"n": []string{"foo"}},
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedBodyErr: &v2.ErrorCodePaginationNumberInvalid,
|
|
},
|
|
{
|
|
name: "1st page",
|
|
queryParams: url.Values{"n": []string{"2"}},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: []string{
|
|
"2j2ar",
|
|
"asj9e",
|
|
}},
|
|
expectedLinkHeader: `</v2/test/tags/list?last=asj9e&n=2>; rel="next"`,
|
|
},
|
|
{
|
|
name: "nth page",
|
|
queryParams: url.Values{"last": []string{"asj9e"}, "n": []string{"1"}},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: []string{
|
|
"jyi7b",
|
|
}},
|
|
expectedLinkHeader: `</v2/test/tags/list?last=jyi7b&n=1>; rel="next"`,
|
|
},
|
|
{
|
|
name: "last page",
|
|
queryParams: url.Values{"last": []string{"jyi7b"}, "n": []string{"3"}},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: []string{
|
|
"kb0j5",
|
|
"sb71y",
|
|
}},
|
|
},
|
|
{
|
|
name: "page size bigger than full list",
|
|
queryParams: url.Values{"n": []string{"100"}},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: tags},
|
|
},
|
|
{
|
|
name: "after marker",
|
|
queryParams: url.Values{"last": []string{"jyi7b"}},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: []string{
|
|
"kb0j5",
|
|
"sb71y",
|
|
}},
|
|
},
|
|
{
|
|
name: "after non existent marker",
|
|
queryParams: url.Values{"last": []string{"does-not-exist"}, "n": []string{"3"}},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: []string{
|
|
"kb0j5",
|
|
"sb71y",
|
|
}},
|
|
},
|
|
}
|
|
|
|
for _, test := range tt {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
tagsURL, err := env.builder.BuildTagsURL(imageName, test.queryParams)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building tags URL: %v", err)
|
|
}
|
|
|
|
resp, err := http.Get(tagsURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != test.expectedStatusCode {
|
|
t.Fatalf("expected response status code to be %d, got %d", test.expectedStatusCode, resp.StatusCode)
|
|
}
|
|
|
|
if test.expectedBodyErr != nil {
|
|
checkBodyHasErrorCodes(t, "invalid number of results requested", resp, *test.expectedBodyErr)
|
|
} else {
|
|
var body tagsAPIResponse
|
|
dec := json.NewDecoder(resp.Body)
|
|
if err := dec.Decode(&body); err != nil {
|
|
t.Fatalf("unexpected error decoding response body: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(body, test.expectedBody) {
|
|
t.Fatalf("expected response body to be:\n%+v\ngot:\n%+v", test.expectedBody, body)
|
|
}
|
|
}
|
|
|
|
if resp.Header.Get("Link") != test.expectedLinkHeader {
|
|
t.Fatalf("expected response Link header to be %q, got %q", test.expectedLinkHeader, resp.Header.Get("Link"))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func checkLink(t *testing.T, urlStr string, numEntries int, last string) url.Values {
|
|
re := regexp.MustCompile("<(/v2/_catalog.*)>; rel=\"next\"")
|
|
matches := re.FindStringSubmatch(urlStr)
|
|
|
|
if len(matches) != 2 {
|
|
t.Fatalf("Catalog link address response was incorrect")
|
|
}
|
|
linkURL, _ := url.Parse(matches[1])
|
|
urlValues := linkURL.Query()
|
|
|
|
if urlValues.Get("n") != strconv.Itoa(numEntries) {
|
|
t.Fatalf("Catalog link entry size is incorrect (expected: %v, returned: %v)", urlValues.Get("n"), strconv.Itoa(numEntries))
|
|
}
|
|
|
|
if urlValues.Get("last") != last {
|
|
t.Fatal("Catalog link last entry is incorrect")
|
|
}
|
|
|
|
return urlValues
|
|
}
|
|
|
|
func contains(elems []string, e string) bool {
|
|
for _, elem := range elems {
|
|
if elem == e {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestURLPrefix(t *testing.T) {
|
|
config := configuration.Configuration{
|
|
Storage: configuration.Storage{
|
|
"inmemory": configuration.Parameters{},
|
|
"maintenance": configuration.Parameters{"uploadpurging": map[interface{}]interface{}{
|
|
"enabled": false,
|
|
}},
|
|
},
|
|
}
|
|
config.HTTP.Prefix = "/test/"
|
|
config.HTTP.Headers = headerConfig
|
|
|
|
env := newTestEnvWithConfig(t, &config)
|
|
defer env.Shutdown()
|
|
|
|
baseURL, err := env.builder.BuildBaseURL()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building base url: %v", err)
|
|
}
|
|
|
|
parsed, _ := url.Parse(baseURL)
|
|
if !strings.HasPrefix(parsed.Path, config.HTTP.Prefix) {
|
|
t.Fatalf("Prefix %v not included in test url %v", config.HTTP.Prefix, baseURL)
|
|
}
|
|
|
|
resp, err := http.Get(baseURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error issuing request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "issuing api base check", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Content-Type": []string{"application/json"},
|
|
"Content-Length": []string{"2"},
|
|
})
|
|
}
|
|
|
|
type blobArgs struct {
|
|
imageName reference.Named
|
|
layerFile io.ReadSeeker
|
|
layerDigest digest.Digest
|
|
}
|
|
|
|
func makeBlobArgs(t *testing.T) blobArgs {
|
|
layerFile, layerDigest, err := testutil.CreateRandomTarFile()
|
|
if err != nil {
|
|
t.Fatalf("error creating random layer file: %v", err)
|
|
}
|
|
|
|
args := blobArgs{
|
|
layerFile: layerFile,
|
|
layerDigest: layerDigest,
|
|
}
|
|
args.imageName, _ = reference.WithName("foo/bar")
|
|
return args
|
|
}
|
|
|
|
// TestBlobAPI conducts a full test of the of the blob api.
|
|
func TestBlobAPI(t *testing.T) {
|
|
deleteEnabled := false
|
|
env1 := newTestEnv(t, deleteEnabled)
|
|
defer env1.Shutdown()
|
|
args := makeBlobArgs(t)
|
|
testBlobAPI(t, env1, args)
|
|
|
|
deleteEnabled = true
|
|
env2 := newTestEnv(t, deleteEnabled)
|
|
defer env2.Shutdown()
|
|
args = makeBlobArgs(t)
|
|
testBlobAPI(t, env2, args)
|
|
}
|
|
|
|
func TestBlobDelete(t *testing.T) {
|
|
deleteEnabled := true
|
|
env := newTestEnv(t, deleteEnabled)
|
|
defer env.Shutdown()
|
|
|
|
args := makeBlobArgs(t)
|
|
env = testBlobAPI(t, env, args)
|
|
testBlobDelete(t, env, args)
|
|
}
|
|
|
|
func TestRelativeURL(t *testing.T) {
|
|
config := configuration.Configuration{
|
|
Storage: configuration.Storage{
|
|
"inmemory": configuration.Parameters{},
|
|
"maintenance": configuration.Parameters{"uploadpurging": map[interface{}]interface{}{
|
|
"enabled": false,
|
|
}},
|
|
},
|
|
}
|
|
config.HTTP.Headers = headerConfig
|
|
config.HTTP.RelativeURLs = false
|
|
env := newTestEnvWithConfig(t, &config)
|
|
defer env.Shutdown()
|
|
ref, _ := reference.WithName("foo/bar")
|
|
uploadURLBaseAbs, _ := startPushLayer(t, env, ref)
|
|
|
|
u, err := url.Parse(uploadURLBaseAbs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !u.IsAbs() {
|
|
t.Fatal("Relative URL returned from blob upload chunk with non-relative configuration")
|
|
}
|
|
|
|
args := makeBlobArgs(t)
|
|
resp, err := doPushLayer(t, env.builder, ref, args.layerDigest, uploadURLBaseAbs, args.layerFile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error doing layer push relative url: %v", err)
|
|
}
|
|
checkResponse(t, "relativeurl blob upload", resp, http.StatusCreated)
|
|
u, err = url.Parse(resp.Header.Get("Location"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !u.IsAbs() {
|
|
t.Fatal("Relative URL returned from blob upload with non-relative configuration")
|
|
}
|
|
|
|
config.HTTP.RelativeURLs = true
|
|
args = makeBlobArgs(t)
|
|
uploadURLBaseRelative, _ := startPushLayer(t, env, ref)
|
|
u, err = url.Parse(uploadURLBaseRelative)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u.IsAbs() {
|
|
t.Fatal("Absolute URL returned from blob upload chunk with relative configuration")
|
|
}
|
|
|
|
// Start a new upload in absolute mode to get a valid base URL
|
|
config.HTTP.RelativeURLs = false
|
|
uploadURLBaseAbs, _ = startPushLayer(t, env, ref)
|
|
u, err = url.Parse(uploadURLBaseAbs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !u.IsAbs() {
|
|
t.Fatal("Relative URL returned from blob upload chunk with non-relative configuration")
|
|
}
|
|
|
|
// Complete upload with relative URLs enabled to ensure the final location is relative
|
|
config.HTTP.RelativeURLs = true
|
|
resp, err = doPushLayer(t, env.builder, ref, args.layerDigest, uploadURLBaseAbs, args.layerFile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error doing layer push relative url: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "relativeurl blob upload", resp, http.StatusCreated)
|
|
u, err = url.Parse(resp.Header.Get("Location"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u.IsAbs() {
|
|
t.Fatal("Relative URL returned from blob upload with non-relative configuration")
|
|
}
|
|
}
|
|
|
|
func TestBlobDeleteDisabled(t *testing.T) {
|
|
deleteEnabled := false
|
|
env := newTestEnv(t, deleteEnabled)
|
|
defer env.Shutdown()
|
|
args := makeBlobArgs(t)
|
|
|
|
imageName := args.imageName
|
|
layerDigest := args.layerDigest
|
|
ref, _ := reference.WithDigest(imageName, layerDigest)
|
|
layerURL, err := env.builder.BuildBlobURL(ref)
|
|
if err != nil {
|
|
t.Fatalf("error building url: %v", err)
|
|
}
|
|
|
|
resp, err := httpDelete(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error deleting when disabled: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "status of disabled delete", resp, http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
func testBlobAPI(t *testing.T, env *testEnv, args blobArgs) *testEnv {
|
|
// TODO(stevvooe): This test code is complete junk but it should cover the
|
|
// complete flow. This must be broken down and checked against the
|
|
// specification *before* we submit the final to docker core.
|
|
imageName := args.imageName
|
|
layerFile := args.layerFile
|
|
layerDigest := args.layerDigest
|
|
|
|
// -----------------------------------
|
|
// Test fetch for non-existent content
|
|
ref, _ := reference.WithDigest(imageName, layerDigest)
|
|
layerURL, err := env.builder.BuildBlobURL(ref)
|
|
if err != nil {
|
|
t.Fatalf("error building url: %v", err)
|
|
}
|
|
|
|
resp, err := http.Get(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching non-existent layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching non-existent content", resp, http.StatusNotFound)
|
|
|
|
// ------------------------------------------
|
|
// Test head request for non-existent content
|
|
resp, err = http.Head(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error checking head on non-existent layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "checking head on non-existent layer", resp, http.StatusNotFound)
|
|
|
|
// ------------------------------------------
|
|
// Start an upload, check the status then cancel
|
|
uploadURLBase, uploadUUID := startPushLayer(t, env, imageName)
|
|
|
|
// A status check should work
|
|
resp, err = http.Get(uploadURLBase)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting upload status: %v", err)
|
|
}
|
|
checkResponse(t, "status of deleted upload", resp, http.StatusNoContent)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{"*"},
|
|
"Range": []string{"0-0"},
|
|
"Docker-Upload-UUID": []string{uploadUUID},
|
|
})
|
|
|
|
req, err := http.NewRequest(http.MethodDelete, uploadURLBase, nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error creating delete request: %v", err)
|
|
}
|
|
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error sending delete request: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "deleting upload", resp, http.StatusNoContent)
|
|
|
|
// A status check should result in 404
|
|
resp, err = http.Get(uploadURLBase)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting upload status: %v", err)
|
|
}
|
|
checkResponse(t, "status of deleted upload", resp, http.StatusNotFound)
|
|
|
|
// -----------------------------------------
|
|
// Do layer push with an empty body and different digest
|
|
uploadURLBase, _ = startPushLayer(t, env, imageName)
|
|
resp, err = doPushLayer(t, env.builder, imageName, layerDigest, uploadURLBase, bytes.NewReader([]byte{}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error doing bad layer push: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "bad layer push", resp, http.StatusBadRequest)
|
|
checkBodyHasErrorCodes(t, "bad layer push", resp, v2.ErrorCodeDigestInvalid)
|
|
|
|
// -----------------------------------------
|
|
// Do layer push with an empty body and correct digest
|
|
zeroDigest, err := digest.FromReader(bytes.NewReader([]byte{}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error digesting empty buffer: %v", err)
|
|
}
|
|
|
|
uploadURLBase, _ = startPushLayer(t, env, imageName)
|
|
pushLayer(t, env.builder, imageName, zeroDigest, uploadURLBase, bytes.NewReader([]byte{}))
|
|
|
|
// -----------------------------------------
|
|
// Do layer push with an empty body and correct digest
|
|
|
|
// This is a valid but empty tarfile!
|
|
emptyTar := bytes.Repeat([]byte("\x00"), 1024)
|
|
emptyDigest, err := digest.FromReader(bytes.NewReader(emptyTar))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error digesting empty tar: %v", err)
|
|
}
|
|
|
|
uploadURLBase, _ = startPushLayer(t, env, imageName)
|
|
pushLayer(t, env.builder, imageName, emptyDigest, uploadURLBase, bytes.NewReader(emptyTar))
|
|
|
|
// ------------------------------------------
|
|
// Now, actually do successful upload.
|
|
layerLength, _ := layerFile.Seek(0, io.SeekEnd)
|
|
layerFile.Seek(0, io.SeekStart)
|
|
|
|
uploadURLBase, _ = startPushLayer(t, env, imageName)
|
|
pushLayer(t, env.builder, imageName, layerDigest, uploadURLBase, layerFile)
|
|
|
|
// ------------------------------------------
|
|
// Now, push just a chunk
|
|
layerFile.Seek(0, 0)
|
|
|
|
canonicalDigester := digest.Canonical.Digester()
|
|
if _, err := io.Copy(canonicalDigester.Hash(), layerFile); err != nil {
|
|
t.Fatalf("error copying to digest: %v", err)
|
|
}
|
|
canonicalDigest := canonicalDigester.Digest()
|
|
|
|
layerFile.Seek(0, 0)
|
|
uploadURLBase, _ = startPushLayer(t, env, imageName)
|
|
uploadURLBase, dgst := pushChunk(t, env.builder, imageName, uploadURLBase, layerFile, layerLength)
|
|
|
|
// -----------------------------------------
|
|
// Check the chunk upload status
|
|
_, end, err := getUploadStatus(uploadURLBase)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error doing chunk upload check: %v", err)
|
|
}
|
|
if end+1 != layerLength {
|
|
t.Fatalf("getting wrong chunk upload status: %d", end)
|
|
}
|
|
|
|
finishUpload(t, env.builder, imageName, uploadURLBase, dgst)
|
|
|
|
// -----------------------------------------
|
|
// Do layer push with invalid content range
|
|
layerFile.Seek(0, io.SeekStart)
|
|
uploadURLBase, _ = startPushLayer(t, env, imageName)
|
|
sizeInvalid := chunkOptions{
|
|
contentRange: "0-20",
|
|
}
|
|
resp, err = doPushChunk(t, uploadURLBase, layerFile, sizeInvalid)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error doing push layer request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
checkResponse(t, "putting size invalid chunk", resp, http.StatusBadRequest)
|
|
|
|
layerFile.Seek(0, io.SeekStart)
|
|
uploadURLBase, _ = startPushLayer(t, env, imageName)
|
|
outOfOrder := chunkOptions{
|
|
contentRange: "3-22",
|
|
}
|
|
resp, err = doPushChunk(t, uploadURLBase, layerFile, outOfOrder)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error doing push layer request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
checkResponse(t, "putting range out of order chunk", resp, http.StatusRequestedRangeNotSatisfiable)
|
|
|
|
// ------------------------
|
|
// Use a head request to see if the layer exists.
|
|
resp, err = http.Head(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error checking head on existing layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "checking head on existing layer", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Content-Length": []string{fmt.Sprint(layerLength)},
|
|
"Docker-Content-Digest": []string{canonicalDigest.String()},
|
|
})
|
|
|
|
// ----------------
|
|
// Fetch the layer!
|
|
resp, err = http.Get(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching layer", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Content-Length": []string{fmt.Sprint(layerLength)},
|
|
"Docker-Content-Digest": []string{canonicalDigest.String()},
|
|
})
|
|
|
|
// Verify the body
|
|
verifier := layerDigest.Verifier()
|
|
io.Copy(verifier, resp.Body)
|
|
|
|
if !verifier.Verified() {
|
|
t.Fatalf("response body did not pass verification")
|
|
}
|
|
|
|
// ----------------
|
|
// Fetch the layer with an invalid digest
|
|
badURL := strings.Replace(layerURL, "sha256", "sha257", 1)
|
|
resp, err = http.Get(badURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching layer bad digest", resp, http.StatusBadRequest)
|
|
|
|
// Cache headers
|
|
resp, err = http.Get(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching layer", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Content-Length": []string{fmt.Sprint(layerLength)},
|
|
"Docker-Content-Digest": []string{canonicalDigest.String()},
|
|
"ETag": []string{fmt.Sprintf(`"%s"`, canonicalDigest)},
|
|
"Cache-Control": []string{"max-age=31536000"},
|
|
})
|
|
|
|
// Matching etag, gives 304
|
|
etag := resp.Header.Get("Etag")
|
|
req, err = http.NewRequest(http.MethodGet, layerURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("If-None-Match", etag)
|
|
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching layer with etag", resp, http.StatusNotModified)
|
|
|
|
// Non-matching etag, gives 200
|
|
req, err = http.NewRequest(http.MethodGet, layerURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("If-None-Match", "")
|
|
resp, _ = http.DefaultClient.Do(req)
|
|
checkResponse(t, "fetching layer with invalid etag", resp, http.StatusOK)
|
|
|
|
// Missing tests:
|
|
// - Upload the same tar file under and different repository and
|
|
// ensure the content remains uncorrupted.
|
|
return env
|
|
}
|
|
|
|
func testBlobDelete(t *testing.T, env *testEnv, args blobArgs) {
|
|
// Upload a layer
|
|
imageName := args.imageName
|
|
layerFile := args.layerFile
|
|
layerDigest := args.layerDigest
|
|
|
|
ref, _ := reference.WithDigest(imageName, layerDigest)
|
|
layerURL, err := env.builder.BuildBlobURL(ref)
|
|
if err != nil {
|
|
t.Fatalf(err.Error())
|
|
}
|
|
// ---------------
|
|
// Delete a layer
|
|
resp, err := httpDelete(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error deleting layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "deleting layer", resp, http.StatusAccepted)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Content-Length": []string{"0"},
|
|
})
|
|
|
|
// ---------------
|
|
// Try and get it back
|
|
// Use a head request to see if the layer exists.
|
|
resp, err = http.Head(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error checking head on existing layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "checking existence of deleted layer", resp, http.StatusNotFound)
|
|
|
|
// Delete already deleted layer
|
|
resp, err = httpDelete(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error deleting layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "deleting layer", resp, http.StatusNotFound)
|
|
|
|
// ----------------
|
|
// Attempt to delete a layer with an invalid digest
|
|
badURL := strings.Replace(layerURL, "sha256", "sha257", 1)
|
|
resp, err = httpDelete(badURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "deleting layer bad digest", resp, http.StatusBadRequest)
|
|
|
|
// ----------------
|
|
// Reupload previously deleted blob
|
|
layerFile.Seek(0, io.SeekStart)
|
|
|
|
uploadURLBase, _ := startPushLayer(t, env, imageName)
|
|
pushLayer(t, env.builder, imageName, layerDigest, uploadURLBase, layerFile)
|
|
|
|
layerFile.Seek(0, io.SeekStart)
|
|
canonicalDigester := digest.Canonical.Digester()
|
|
if _, err := io.Copy(canonicalDigester.Hash(), layerFile); err != nil {
|
|
t.Fatalf("error copying to digest: %v", err)
|
|
}
|
|
canonicalDigest := canonicalDigester.Digest()
|
|
|
|
// ------------------------
|
|
// Use a head request to see if it exists
|
|
resp, err = http.Head(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error checking head on existing layer: %v", err)
|
|
}
|
|
|
|
layerLength, _ := layerFile.Seek(0, io.SeekEnd)
|
|
checkResponse(t, "checking head on reuploaded layer", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Content-Length": []string{fmt.Sprint(layerLength)},
|
|
"Docker-Content-Digest": []string{canonicalDigest.String()},
|
|
})
|
|
}
|
|
|
|
func TestDeleteDisabled(t *testing.T) {
|
|
env := newTestEnv(t, false)
|
|
defer env.Shutdown()
|
|
|
|
imageName, _ := reference.WithName("foo/bar")
|
|
// "build" our layer file
|
|
layerFile, layerDigest, err := testutil.CreateRandomTarFile()
|
|
if err != nil {
|
|
t.Fatalf("error creating random layer file: %v", err)
|
|
}
|
|
|
|
ref, _ := reference.WithDigest(imageName, layerDigest)
|
|
layerURL, err := env.builder.BuildBlobURL(ref)
|
|
if err != nil {
|
|
t.Fatalf("Error building blob URL")
|
|
}
|
|
uploadURLBase, _ := startPushLayer(t, env, imageName)
|
|
pushLayer(t, env.builder, imageName, layerDigest, uploadURLBase, layerFile)
|
|
|
|
resp, err := httpDelete(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error deleting layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "deleting layer with delete disabled", resp, http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
func TestDeleteReadOnly(t *testing.T) {
|
|
env := newTestEnv(t, true)
|
|
defer env.Shutdown()
|
|
|
|
imageName, _ := reference.WithName("foo/bar")
|
|
// "build" our layer file
|
|
layerFile, layerDigest, err := testutil.CreateRandomTarFile()
|
|
if err != nil {
|
|
t.Fatalf("error creating random layer file: %v", err)
|
|
}
|
|
|
|
ref, _ := reference.WithDigest(imageName, layerDigest)
|
|
layerURL, err := env.builder.BuildBlobURL(ref)
|
|
if err != nil {
|
|
t.Fatalf("Error building blob URL")
|
|
}
|
|
uploadURLBase, _ := startPushLayer(t, env, imageName)
|
|
pushLayer(t, env.builder, imageName, layerDigest, uploadURLBase, layerFile)
|
|
|
|
env.app.readOnly = true
|
|
|
|
resp, err := httpDelete(layerURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error deleting layer: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "deleting layer in read-only mode", resp, http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
func TestStartPushReadOnly(t *testing.T) {
|
|
env := newTestEnv(t, true)
|
|
defer env.Shutdown()
|
|
env.app.readOnly = true
|
|
|
|
imageName, _ := reference.WithName("foo/bar")
|
|
|
|
layerUploadURL, err := env.builder.BuildBlobUploadURL(imageName)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building layer upload url: %v", err)
|
|
}
|
|
|
|
resp, err := http.Post(layerUploadURL, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error starting layer push: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "starting push in read-only mode", resp, http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
func httpDelete(url string) (*http.Response, error) {
|
|
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// defer resp.Body.Close()
|
|
return resp, err
|
|
}
|
|
|
|
type manifestArgs struct {
|
|
imageName reference.Named
|
|
mediaType string
|
|
manifest distribution.Manifest
|
|
dgst digest.Digest
|
|
}
|
|
|
|
func TestManifestAPI(t *testing.T) {
|
|
schema1Repo, _ := reference.WithName("foo/schema1")
|
|
schema2Repo, _ := reference.WithName("foo/schema2")
|
|
|
|
deleteEnabled := false
|
|
env1 := newTestEnv(t, deleteEnabled)
|
|
defer env1.Shutdown()
|
|
testManifestAPISchema1(t, env1, schema1Repo)
|
|
schema2Args := testManifestAPISchema2(t, env1, schema2Repo)
|
|
testManifestAPIManifestList(t, env1, schema2Args)
|
|
|
|
deleteEnabled = true
|
|
env2 := newTestEnv(t, deleteEnabled)
|
|
defer env2.Shutdown()
|
|
testManifestAPISchema1(t, env2, schema1Repo)
|
|
schema2Args = testManifestAPISchema2(t, env2, schema2Repo)
|
|
testManifestAPIManifestList(t, env2, schema2Args)
|
|
}
|
|
|
|
func TestManifestAPI_DeleteTag(t *testing.T) {
|
|
env := newTestEnv(t, false)
|
|
defer env.Shutdown()
|
|
|
|
imageName, err := reference.WithName("foo/bar")
|
|
checkErr(t, err, "building image name")
|
|
|
|
tag := "latest"
|
|
dgst := createRepository(env, t, imageName.Name(), tag)
|
|
|
|
ref, err := reference.WithTag(imageName, tag)
|
|
checkErr(t, err, "building tag reference")
|
|
|
|
u, err := env.builder.BuildManifestURL(ref)
|
|
checkErr(t, err, "building tag URL")
|
|
|
|
resp, err := httpDelete(u)
|
|
m := "deleting tag"
|
|
checkErr(t, err, m)
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, m, resp, http.StatusAccepted)
|
|
if resp.Body != http.NoBody {
|
|
t.Fatal("unexpected response body")
|
|
}
|
|
|
|
msg := "checking tag no longer exists"
|
|
resp, err = http.Get(u)
|
|
checkErr(t, err, msg)
|
|
checkResponse(t, msg, resp, http.StatusNotFound)
|
|
|
|
digestRef, err := reference.WithDigest(imageName, dgst)
|
|
checkErr(t, err, "building manifest digest reference")
|
|
|
|
u, err = env.builder.BuildManifestURL(digestRef)
|
|
checkErr(t, err, "building manifest URL")
|
|
|
|
msg = "checking manifest still exists"
|
|
resp, err = http.Head(u)
|
|
checkErr(t, err, msg)
|
|
checkResponse(t, msg, resp, http.StatusOK)
|
|
}
|
|
|
|
func TestManifestAPI_DeleteTag_Unknown(t *testing.T) {
|
|
env := newTestEnv(t, false)
|
|
defer env.Shutdown()
|
|
|
|
imageName, err := reference.WithName("foo/bar")
|
|
checkErr(t, err, "building named object")
|
|
|
|
ref, err := reference.WithTag(imageName, "latest")
|
|
checkErr(t, err, "building tag reference")
|
|
|
|
u, err := env.builder.BuildManifestURL(ref)
|
|
checkErr(t, err, "building tag URL")
|
|
|
|
resp, err := httpDelete(u)
|
|
msg := "deleting unknown tag"
|
|
checkErr(t, err, msg)
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, msg, resp, http.StatusNotFound)
|
|
checkBodyHasErrorCodes(t, msg, resp, v2.ErrorCodeManifestUnknown)
|
|
}
|
|
|
|
func TestManifestAPI_DeleteTag_ReadOnly(t *testing.T) {
|
|
env := newTestEnv(t, false)
|
|
defer env.Shutdown()
|
|
env.app.readOnly = true
|
|
|
|
imageName, err := reference.WithName("foo/bar")
|
|
checkErr(t, err, "building named object")
|
|
|
|
ref, err := reference.WithTag(imageName, "latest")
|
|
checkErr(t, err, "building tag reference")
|
|
|
|
u, err := env.builder.BuildManifestURL(ref)
|
|
checkErr(t, err, "building URL")
|
|
|
|
resp, err := httpDelete(u)
|
|
msg := "deleting tag"
|
|
checkErr(t, err, msg)
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, msg, resp, http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
// storageManifestErrDriverFactory implements the factory.StorageDriverFactory interface.
|
|
type storageManifestErrDriverFactory struct{}
|
|
|
|
const (
|
|
repositoryWithManifestNotFound = "manifesttagnotfound"
|
|
repositoryWithManifestInvalidPath = "manifestinvalidpath"
|
|
repositoryWithManifestBadLink = "manifestbadlink"
|
|
repositoryWithGenericStorageError = "genericstorageerr"
|
|
)
|
|
|
|
func (factory *storageManifestErrDriverFactory) Create(parameters map[string]interface{}) (storagedriver.StorageDriver, error) {
|
|
// Initialize the mock driver
|
|
errGenericStorage := errors.New("generic storage error")
|
|
return &mockErrorDriver{
|
|
returnErrs: []mockErrorMapping{
|
|
{
|
|
pathMatch: fmt.Sprintf("%s/_manifests/tags", repositoryWithManifestNotFound),
|
|
content: nil,
|
|
err: storagedriver.PathNotFoundError{},
|
|
},
|
|
{
|
|
pathMatch: fmt.Sprintf("%s/_manifests/tags", repositoryWithManifestInvalidPath),
|
|
content: nil,
|
|
err: storagedriver.InvalidPathError{},
|
|
},
|
|
{
|
|
pathMatch: fmt.Sprintf("%s/_manifests/tags", repositoryWithManifestBadLink),
|
|
content: []byte("this is a bad sha"),
|
|
err: nil,
|
|
},
|
|
{
|
|
pathMatch: fmt.Sprintf("%s/_manifests/tags", repositoryWithGenericStorageError),
|
|
content: nil,
|
|
err: errGenericStorage,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
type mockErrorMapping struct {
|
|
pathMatch string
|
|
content []byte
|
|
err error
|
|
}
|
|
|
|
// mockErrorDriver implements StorageDriver to force storage error on manifest request
|
|
type mockErrorDriver struct {
|
|
storagedriver.StorageDriver
|
|
returnErrs []mockErrorMapping
|
|
}
|
|
|
|
func (dr *mockErrorDriver) GetContent(ctx context.Context, path string) ([]byte, error) {
|
|
for _, returns := range dr.returnErrs {
|
|
if strings.Contains(path, returns.pathMatch) {
|
|
return returns.content, returns.err
|
|
}
|
|
}
|
|
return nil, errors.New("Unknown storage error")
|
|
}
|
|
|
|
func TestGetManifestWithStorageError(t *testing.T) {
|
|
factory.Register("storagemanifesterror", &storageManifestErrDriverFactory{})
|
|
config := configuration.Configuration{
|
|
Storage: configuration.Storage{
|
|
"storagemanifesterror": configuration.Parameters{},
|
|
"maintenance": configuration.Parameters{"uploadpurging": map[interface{}]interface{}{
|
|
"enabled": false,
|
|
}},
|
|
},
|
|
}
|
|
config.HTTP.Headers = headerConfig
|
|
env1 := newTestEnvWithConfig(t, &config)
|
|
defer env1.Shutdown()
|
|
|
|
repo, _ := reference.WithName(repositoryWithManifestNotFound)
|
|
testManifestWithStorageError(t, env1, repo, http.StatusNotFound, v2.ErrorCodeManifestUnknown)
|
|
|
|
repo, _ = reference.WithName(repositoryWithGenericStorageError)
|
|
testManifestWithStorageError(t, env1, repo, http.StatusInternalServerError, errcode.ErrorCodeUnknown)
|
|
|
|
repo, _ = reference.WithName(repositoryWithManifestInvalidPath)
|
|
testManifestWithStorageError(t, env1, repo, http.StatusInternalServerError, errcode.ErrorCodeUnknown)
|
|
|
|
repo, _ = reference.WithName(repositoryWithManifestBadLink)
|
|
testManifestWithStorageError(t, env1, repo, http.StatusInternalServerError, errcode.ErrorCodeUnknown)
|
|
}
|
|
|
|
func TestManifestDelete(t *testing.T) {
|
|
schema1Repo, _ := reference.WithName("foo/schema1")
|
|
schema2Repo, _ := reference.WithName("foo/schema2")
|
|
|
|
deleteEnabled := true
|
|
env := newTestEnv(t, deleteEnabled)
|
|
defer env.Shutdown()
|
|
schema1Args := testManifestAPISchema1(t, env, schema1Repo)
|
|
testManifestDelete(t, env, schema1Args)
|
|
schema2Args := testManifestAPISchema2(t, env, schema2Repo)
|
|
testManifestDelete(t, env, schema2Args)
|
|
}
|
|
|
|
func TestManifestDeleteDisabled(t *testing.T) {
|
|
schema1Repo, _ := reference.WithName("foo/schema1")
|
|
deleteEnabled := false
|
|
env := newTestEnv(t, deleteEnabled)
|
|
defer env.Shutdown()
|
|
testManifestDeleteDisabled(t, env, schema1Repo)
|
|
}
|
|
|
|
func testManifestDeleteDisabled(t *testing.T, env *testEnv, imageName reference.Named) {
|
|
ref, _ := reference.WithDigest(imageName, digestSha256EmptyTar)
|
|
manifestURL, err := env.builder.BuildManifestURL(ref)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting manifest url: %v", err)
|
|
}
|
|
|
|
resp, err := httpDelete(manifestURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error deleting manifest %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "status of disabled delete of manifest", resp, http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
func testManifestWithStorageError(t *testing.T, env *testEnv, imageName reference.Named, expectedStatusCode int, expectedErrorCode errcode.ErrorCode) {
|
|
tag := "latest"
|
|
tagRef, _ := reference.WithTag(imageName, tag)
|
|
manifestURL, err := env.builder.BuildManifestURL(tagRef)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting manifest url: %v", err)
|
|
}
|
|
|
|
// -----------------------------
|
|
// Attempt to fetch the manifest
|
|
resp, err := http.Get(manifestURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting manifest: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
checkResponse(t, "getting non-existent manifest", resp, expectedStatusCode)
|
|
checkBodyHasErrorCodes(t, "getting non-existent manifest", resp, expectedErrorCode)
|
|
}
|
|
|
|
func testManifestAPISchema1(t *testing.T, env *testEnv, imageName reference.Named) manifestArgs {
|
|
tag := "thetag"
|
|
args := manifestArgs{imageName: imageName}
|
|
|
|
tagRef, _ := reference.WithTag(imageName, tag)
|
|
manifestURL, err := env.builder.BuildManifestURL(tagRef)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting manifest url: %v", err)
|
|
}
|
|
|
|
// -----------------------------
|
|
// Attempt to fetch the manifest
|
|
resp, err := http.Get(manifestURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting manifest: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "getting non-existent manifest", resp, http.StatusNotFound)
|
|
checkBodyHasErrorCodes(t, "getting non-existent manifest", resp, v2.ErrorCodeManifestUnknown)
|
|
|
|
tagsURL, err := env.builder.BuildTagsURL(imageName)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building tags url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(tagsURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting unknown tags: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check that we get an unknown repository error when asking for tags
|
|
checkResponse(t, "getting unknown manifest tags", resp, http.StatusNotFound)
|
|
checkBodyHasErrorCodes(t, "getting unknown manifest tags", resp, v2.ErrorCodeNameUnknown)
|
|
|
|
// --------------------------------
|
|
// Attempt to push unsigned manifest with missing layers
|
|
unsignedManifest := &schema1.Manifest{ //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
Versioned: manifest.Versioned{
|
|
SchemaVersion: 1,
|
|
},
|
|
Name: imageName.Name(),
|
|
Tag: tag,
|
|
FSLayers: []schema1.FSLayer{ //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
{
|
|
BlobSum: "asdf",
|
|
},
|
|
{
|
|
BlobSum: "qwer",
|
|
},
|
|
},
|
|
History: []schema1.History{ //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
{
|
|
V1Compatibility: "",
|
|
},
|
|
{
|
|
V1Compatibility: "",
|
|
},
|
|
},
|
|
}
|
|
|
|
resp = putManifest(t, "putting unsigned manifest", manifestURL, "", unsignedManifest)
|
|
defer resp.Body.Close()
|
|
checkResponse(t, "putting unsigned manifest", resp, http.StatusBadRequest)
|
|
_, p, counts := checkBodyHasErrorCodes(t, "putting unsigned manifest", resp, v2.ErrorCodeManifestInvalid)
|
|
|
|
expectedCounts := map[errcode.ErrorCode]int{
|
|
v2.ErrorCodeManifestInvalid: 1,
|
|
}
|
|
|
|
if !reflect.DeepEqual(counts, expectedCounts) {
|
|
t.Fatalf("unexpected number of error codes encountered: %v\n!=\n%v\n---\n%s", counts, expectedCounts, string(p))
|
|
}
|
|
|
|
// sign the manifest and still get some interesting errors.
|
|
sm, err := schema1.Sign(unsignedManifest, env.pk) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatalf("error signing manifest: %v", err)
|
|
}
|
|
|
|
resp = putManifest(t, "putting signed manifest with errors", manifestURL, "", sm)
|
|
defer resp.Body.Close()
|
|
checkResponse(t, "putting signed manifest with errors", resp, http.StatusBadRequest)
|
|
_, p, counts = checkBodyHasErrorCodes(t, "putting signed manifest with errors", resp,
|
|
v2.ErrorCodeManifestBlobUnknown, v2.ErrorCodeDigestInvalid)
|
|
|
|
expectedCounts = map[errcode.ErrorCode]int{
|
|
v2.ErrorCodeManifestBlobUnknown: 2,
|
|
v2.ErrorCodeDigestInvalid: 2,
|
|
}
|
|
|
|
if !reflect.DeepEqual(counts, expectedCounts) {
|
|
t.Fatalf("unexpected number of error codes encountered: %v\n!=\n%v\n---\n%s", counts, expectedCounts, string(p))
|
|
}
|
|
|
|
// TODO(stevvooe): Add a test case where we take a mostly valid registry,
|
|
// tamper with the content and ensure that we get an unverified manifest
|
|
// error.
|
|
|
|
// Push 2 random layers
|
|
expectedLayers := make(map[digest.Digest]io.ReadSeeker)
|
|
|
|
for i := range unsignedManifest.FSLayers {
|
|
rs, dgst, err := testutil.CreateRandomTarFile()
|
|
if err != nil {
|
|
t.Fatalf("error creating random layer %d: %v", i, err)
|
|
}
|
|
|
|
expectedLayers[dgst] = rs
|
|
unsignedManifest.FSLayers[i].BlobSum = dgst
|
|
|
|
uploadURLBase, _ := startPushLayer(t, env, imageName)
|
|
pushLayer(t, env.builder, imageName, dgst, uploadURLBase, rs)
|
|
}
|
|
|
|
// -------------------
|
|
// Push the signed manifest with all layers pushed.
|
|
signedManifest, err := schema1.Sign(unsignedManifest, env.pk) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatalf("unexpected error signing manifest: %v", err)
|
|
}
|
|
|
|
dgst := digest.FromBytes(signedManifest.Canonical)
|
|
args.manifest = signedManifest
|
|
args.dgst = dgst
|
|
|
|
digestRef, _ := reference.WithDigest(imageName, dgst)
|
|
manifestDigestURL, err := env.builder.BuildManifestURL(digestRef)
|
|
checkErr(t, err, "building manifest url")
|
|
|
|
resp = putManifest(t, "putting signed manifest no error", manifestURL, "", signedManifest)
|
|
checkResponse(t, "putting signed manifest no error", resp, http.StatusCreated)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{manifestDigestURL},
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
// --------------------
|
|
// Push by digest -- should get same result
|
|
resp = putManifest(t, "putting signed manifest", manifestDigestURL, "", signedManifest)
|
|
checkResponse(t, "putting signed manifest", resp, http.StatusCreated)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{manifestDigestURL},
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
// ------------------
|
|
// Fetch by tag name
|
|
resp, err = http.Get(manifestURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching manifest: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "fetching uploaded manifest", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
"ETag": []string{fmt.Sprintf(`"%s"`, dgst)},
|
|
})
|
|
|
|
var fetchedManifest schema1.SignedManifest //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
dec := json.NewDecoder(resp.Body)
|
|
|
|
if err := dec.Decode(&fetchedManifest); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
if !bytes.Equal(fetchedManifest.Canonical, signedManifest.Canonical) {
|
|
t.Fatalf("manifests do not match")
|
|
}
|
|
|
|
// ---------------
|
|
// Fetch by digest
|
|
resp, err = http.Get(manifestDigestURL)
|
|
checkErr(t, err, "fetching manifest by digest")
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "fetching uploaded manifest", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
"ETag": []string{fmt.Sprintf(`"%s"`, dgst)},
|
|
})
|
|
|
|
var fetchedManifestByDigest schema1.SignedManifest //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err := dec.Decode(&fetchedManifestByDigest); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
if !bytes.Equal(fetchedManifestByDigest.Canonical, signedManifest.Canonical) {
|
|
t.Fatalf("manifests do not match")
|
|
}
|
|
|
|
// check signature was roundtripped
|
|
signatures, err := fetchedManifestByDigest.Signatures() //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(signatures) != 1 {
|
|
t.Fatalf("expected 1 signature from manifest, got: %d", len(signatures))
|
|
}
|
|
|
|
// Re-sign, push and pull the same digest
|
|
sm2, err := schema1.Sign(&fetchedManifestByDigest.Manifest, env.pk) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Re-push with a few different Content-Types. The official schema1
|
|
// content type should work, as should application/json with/without a
|
|
// charset.
|
|
resp = putManifest(t, "re-putting signed manifest", manifestDigestURL, schema1.MediaTypeSignedManifest, sm2) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
checkResponse(t, "re-putting signed manifest", resp, http.StatusCreated)
|
|
resp = putManifest(t, "re-putting signed manifest", manifestDigestURL, "application/json", sm2)
|
|
checkResponse(t, "re-putting signed manifest", resp, http.StatusCreated)
|
|
resp = putManifest(t, "re-putting signed manifest", manifestDigestURL, "application/json", sm2)
|
|
checkResponse(t, "re-putting signed manifest", resp, http.StatusCreated)
|
|
|
|
resp, err = http.Get(manifestDigestURL)
|
|
checkErr(t, err, "re-fetching manifest by digest")
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "re-fetching uploaded manifest", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
"ETag": []string{fmt.Sprintf(`"%s"`, dgst)},
|
|
})
|
|
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err := dec.Decode(&fetchedManifestByDigest); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
// check only 1 signature is returned
|
|
signatures, err = fetchedManifestByDigest.Signatures() //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(signatures) != 1 {
|
|
t.Fatalf("expected 2 signature from manifest, got: %d", len(signatures))
|
|
}
|
|
|
|
// Get by name with etag, gives 304
|
|
etag := resp.Header.Get("Etag")
|
|
req, err := http.NewRequest(http.MethodGet, manifestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("If-None-Match", etag)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching manifest by name with etag", resp, http.StatusNotModified)
|
|
|
|
// Get by digest with etag, gives 304
|
|
req, err = http.NewRequest(http.MethodGet, manifestDigestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("If-None-Match", etag)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching manifest by dgst with etag", resp, http.StatusNotModified)
|
|
|
|
// Ensure that the tag is listed.
|
|
resp, err = http.Get(tagsURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting unknown tags: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "getting tags", resp, http.StatusOK)
|
|
dec = json.NewDecoder(resp.Body)
|
|
|
|
var tagsResponse tagsAPIResponse
|
|
|
|
if err := dec.Decode(&tagsResponse); err != nil {
|
|
t.Fatalf("unexpected error decoding error response: %v", err)
|
|
}
|
|
|
|
if tagsResponse.Name != imageName.Name() {
|
|
t.Fatalf("tags name should match image name: %v != %v", tagsResponse.Name, imageName.Name())
|
|
}
|
|
|
|
if len(tagsResponse.Tags) != 1 {
|
|
t.Fatalf("expected some tags in response: %v", tagsResponse.Tags)
|
|
}
|
|
|
|
if tagsResponse.Tags[0] != tag {
|
|
t.Fatalf("tag not as expected: %q != %q", tagsResponse.Tags[0], tag)
|
|
}
|
|
|
|
// Attempt to put a manifest with mismatching FSLayer and History array cardinalities
|
|
|
|
unsignedManifest.History = append(unsignedManifest.History, schema1.History{ //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
V1Compatibility: "",
|
|
})
|
|
invalidSigned, err := schema1.Sign(unsignedManifest, env.pk) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatalf("error signing manifest")
|
|
}
|
|
|
|
resp = putManifest(t, "putting invalid signed manifest", manifestDigestURL, "", invalidSigned)
|
|
checkResponse(t, "putting invalid signed manifest", resp, http.StatusBadRequest)
|
|
|
|
return args
|
|
}
|
|
|
|
func testManifestAPISchema2(t *testing.T, env *testEnv, imageName reference.Named) manifestArgs {
|
|
tag := "schema2tag"
|
|
args := manifestArgs{
|
|
imageName: imageName,
|
|
mediaType: schema2.MediaTypeManifest,
|
|
}
|
|
|
|
tagRef, _ := reference.WithTag(imageName, tag)
|
|
manifestURL, err := env.builder.BuildManifestURL(tagRef)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting manifest url: %v", err)
|
|
}
|
|
|
|
// -----------------------------
|
|
// Attempt to fetch the manifest
|
|
resp, err := http.Get(manifestURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting manifest: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "getting non-existent manifest", resp, http.StatusNotFound)
|
|
checkBodyHasErrorCodes(t, "getting non-existent manifest", resp, v2.ErrorCodeManifestUnknown)
|
|
|
|
tagsURL, err := env.builder.BuildTagsURL(imageName)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building tags url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Get(tagsURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting unknown tags: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check that we get an unknown repository error when asking for tags
|
|
checkResponse(t, "getting unknown manifest tags", resp, http.StatusNotFound)
|
|
checkBodyHasErrorCodes(t, "getting unknown manifest tags", resp, v2.ErrorCodeNameUnknown)
|
|
|
|
// --------------------------------
|
|
// Attempt to push manifest with missing config and missing layers
|
|
manifest := &schema2.Manifest{
|
|
Versioned: manifest.Versioned{
|
|
SchemaVersion: 2,
|
|
MediaType: schema2.MediaTypeManifest,
|
|
},
|
|
Config: distribution.Descriptor{
|
|
Digest: "sha256:1a9ec845ee94c202b2d5da74a24f0ed2058318bfa9879fa541efaecba272e86b",
|
|
Size: 3253,
|
|
MediaType: schema2.MediaTypeImageConfig,
|
|
},
|
|
Layers: []distribution.Descriptor{
|
|
{
|
|
Digest: "sha256:463434349086340864309863409683460843608348608934092322395278926a",
|
|
Size: 6323,
|
|
MediaType: schema2.MediaTypeLayer,
|
|
},
|
|
{
|
|
Digest: "sha256:630923423623623423352523525237238023652897356239852383652aaaaaaa",
|
|
Size: 6863,
|
|
MediaType: schema2.MediaTypeLayer,
|
|
},
|
|
},
|
|
}
|
|
|
|
resp = putManifest(t, "putting missing config manifest", manifestURL, schema2.MediaTypeManifest, manifest)
|
|
defer resp.Body.Close()
|
|
checkResponse(t, "putting missing config manifest", resp, http.StatusBadRequest)
|
|
_, p, counts := checkBodyHasErrorCodes(t, "putting missing config manifest", resp, v2.ErrorCodeManifestBlobUnknown)
|
|
|
|
expectedCounts := map[errcode.ErrorCode]int{
|
|
v2.ErrorCodeManifestBlobUnknown: 3,
|
|
}
|
|
|
|
if !reflect.DeepEqual(counts, expectedCounts) {
|
|
t.Fatalf("unexpected number of error codes encountered: %v\n!=\n%v\n---\n%s", counts, expectedCounts, string(p))
|
|
}
|
|
|
|
// Push a config, and reference it in the manifest
|
|
sampleConfig := []byte(`{
|
|
"architecture": "amd64",
|
|
"history": [
|
|
{
|
|
"created": "2015-10-31T22:22:54.690851953Z",
|
|
"created_by": "/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /"
|
|
},
|
|
{
|
|
"created": "2015-10-31T22:22:55.613815829Z",
|
|
"created_by": "/bin/sh -c #(nop) CMD [\"sh\"]"
|
|
}
|
|
],
|
|
"rootfs": {
|
|
"diff_ids": [
|
|
"sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
|
|
"sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
|
|
],
|
|
"type": "layers"
|
|
}
|
|
}`)
|
|
sampleConfigDigest := digest.FromBytes(sampleConfig)
|
|
|
|
uploadURLBase, _ := startPushLayer(t, env, imageName)
|
|
pushLayer(t, env.builder, imageName, sampleConfigDigest, uploadURLBase, bytes.NewReader(sampleConfig))
|
|
manifest.Config.Digest = sampleConfigDigest
|
|
manifest.Config.Size = int64(len(sampleConfig))
|
|
|
|
// The manifest should still be invalid, because its layer doesn't exist
|
|
resp = putManifest(t, "putting missing layer manifest", manifestURL, schema2.MediaTypeManifest, manifest)
|
|
defer resp.Body.Close()
|
|
checkResponse(t, "putting missing layer manifest", resp, http.StatusBadRequest)
|
|
_, p, counts = checkBodyHasErrorCodes(t, "getting unknown manifest tags", resp, v2.ErrorCodeManifestBlobUnknown)
|
|
|
|
expectedCounts = map[errcode.ErrorCode]int{
|
|
v2.ErrorCodeManifestBlobUnknown: 2,
|
|
}
|
|
|
|
if !reflect.DeepEqual(counts, expectedCounts) {
|
|
t.Fatalf("unexpected number of error codes encountered: %v\n!=\n%v\n---\n%s", counts, expectedCounts, string(p))
|
|
}
|
|
|
|
// Push 2 random layers
|
|
expectedLayers := make(map[digest.Digest]io.ReadSeeker)
|
|
|
|
for i := range manifest.Layers {
|
|
rs, dgst, err := testutil.CreateRandomTarFile()
|
|
if err != nil {
|
|
t.Fatalf("error creating random layer %d: %v", i, err)
|
|
}
|
|
|
|
expectedLayers[dgst] = rs
|
|
manifest.Layers[i].Digest = dgst
|
|
|
|
uploadURLBase, _ := startPushLayer(t, env, imageName)
|
|
pushLayer(t, env.builder, imageName, dgst, uploadURLBase, rs)
|
|
}
|
|
|
|
// -------------------
|
|
// Push the manifest with all layers pushed.
|
|
deserializedManifest, err := schema2.FromStruct(*manifest)
|
|
if err != nil {
|
|
t.Fatalf("could not create DeserializedManifest: %v", err)
|
|
}
|
|
_, canonical, err := deserializedManifest.Payload()
|
|
if err != nil {
|
|
t.Fatalf("could not get manifest payload: %v", err)
|
|
}
|
|
dgst := digest.FromBytes(canonical)
|
|
args.dgst = dgst
|
|
args.manifest = deserializedManifest
|
|
|
|
digestRef, _ := reference.WithDigest(imageName, dgst)
|
|
manifestDigestURL, err := env.builder.BuildManifestURL(digestRef)
|
|
checkErr(t, err, "building manifest url")
|
|
|
|
resp = putManifest(t, "putting manifest no error", manifestURL, schema2.MediaTypeManifest, manifest)
|
|
checkResponse(t, "putting manifest no error", resp, http.StatusCreated)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{manifestDigestURL},
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
// --------------------
|
|
// Push by digest -- should get same result
|
|
resp = putManifest(t, "putting manifest by digest", manifestDigestURL, schema2.MediaTypeManifest, manifest)
|
|
checkResponse(t, "putting manifest by digest", resp, http.StatusCreated)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{manifestDigestURL},
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
// ------------------
|
|
// Fetch by tag name
|
|
req, err := http.NewRequest(http.MethodGet, manifestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("Accept", schema2.MediaTypeManifest)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching manifest: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "fetching uploaded manifest", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
"ETag": []string{fmt.Sprintf(`"%s"`, dgst)},
|
|
})
|
|
|
|
var fetchedManifest schema2.DeserializedManifest
|
|
dec := json.NewDecoder(resp.Body)
|
|
|
|
if err := dec.Decode(&fetchedManifest); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
_, fetchedCanonical, err := fetchedManifest.Payload()
|
|
if err != nil {
|
|
t.Fatalf("error getting manifest payload: %v", err)
|
|
}
|
|
|
|
if !bytes.Equal(fetchedCanonical, canonical) {
|
|
t.Fatalf("manifests do not match")
|
|
}
|
|
|
|
// ---------------
|
|
// Fetch by digest
|
|
req, err = http.NewRequest(http.MethodGet, manifestDigestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("Accept", schema2.MediaTypeManifest)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
checkErr(t, err, "fetching manifest by digest")
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "fetching uploaded manifest", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
"ETag": []string{fmt.Sprintf(`"%s"`, dgst)},
|
|
})
|
|
|
|
var fetchedManifestByDigest schema2.DeserializedManifest
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err := dec.Decode(&fetchedManifestByDigest); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
_, fetchedCanonical, err = fetchedManifest.Payload()
|
|
if err != nil {
|
|
t.Fatalf("error getting manifest payload: %v", err)
|
|
}
|
|
|
|
if !bytes.Equal(fetchedCanonical, canonical) {
|
|
t.Fatalf("manifests do not match")
|
|
}
|
|
|
|
// Get by name with etag, gives 304
|
|
etag := resp.Header.Get("Etag")
|
|
req, err = http.NewRequest(http.MethodGet, manifestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("If-None-Match", etag)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching manifest by name with etag", resp, http.StatusNotModified)
|
|
|
|
// Get by digest with etag, gives 304
|
|
req, err = http.NewRequest(http.MethodGet, manifestDigestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("If-None-Match", etag)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching manifest by dgst with etag", resp, http.StatusNotModified)
|
|
|
|
// Ensure that the tag is listed.
|
|
resp, err = http.Get(tagsURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting unknown tags: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "getting unknown manifest tags", resp, http.StatusOK)
|
|
dec = json.NewDecoder(resp.Body)
|
|
|
|
var tagsResponse tagsAPIResponse
|
|
|
|
if err := dec.Decode(&tagsResponse); err != nil {
|
|
t.Fatalf("unexpected error decoding error response: %v", err)
|
|
}
|
|
|
|
if tagsResponse.Name != imageName.Name() {
|
|
t.Fatalf("tags name should match image name: %v != %v", tagsResponse.Name, imageName)
|
|
}
|
|
|
|
if len(tagsResponse.Tags) != 1 {
|
|
t.Fatalf("expected some tags in response: %v", tagsResponse.Tags)
|
|
}
|
|
|
|
if tagsResponse.Tags[0] != tag {
|
|
t.Fatalf("tag not as expected: %q != %q", tagsResponse.Tags[0], tag)
|
|
}
|
|
|
|
// ------------------
|
|
// Fetch as a schema1 manifest
|
|
resp, err = http.Get(manifestURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching manifest as schema1: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
manifestBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("error reading response body: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching uploaded manifest as schema1", resp, http.StatusOK)
|
|
|
|
m, desc, err := distribution.UnmarshalManifest(schema1.MediaTypeManifest, manifestBytes) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatalf("unexpected error unmarshalling manifest: %v", err)
|
|
}
|
|
|
|
fetchedSchema1Manifest, ok := m.(*schema1.SignedManifest) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if !ok {
|
|
t.Fatalf("expecting schema1 manifest")
|
|
}
|
|
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{desc.Digest.String()},
|
|
"ETag": []string{fmt.Sprintf(`"%s"`, desc.Digest)},
|
|
})
|
|
|
|
if fetchedSchema1Manifest.Manifest.SchemaVersion != 1 {
|
|
t.Fatal("wrong schema version")
|
|
}
|
|
if fetchedSchema1Manifest.Architecture != "amd64" {
|
|
t.Fatal("wrong architecture")
|
|
}
|
|
if fetchedSchema1Manifest.Name != imageName.Name() {
|
|
t.Fatal("wrong image name")
|
|
}
|
|
if fetchedSchema1Manifest.Tag != tag {
|
|
t.Fatal("wrong tag")
|
|
}
|
|
if len(fetchedSchema1Manifest.FSLayers) != 2 {
|
|
t.Fatal("wrong number of FSLayers")
|
|
}
|
|
for i := range manifest.Layers {
|
|
if fetchedSchema1Manifest.FSLayers[i].BlobSum != manifest.Layers[len(manifest.Layers)-i-1].Digest {
|
|
t.Fatalf("blob digest mismatch in schema1 manifest for layer %d", i)
|
|
}
|
|
}
|
|
if len(fetchedSchema1Manifest.History) != 2 {
|
|
t.Fatal("wrong number of History entries")
|
|
}
|
|
|
|
// Don't check V1Compatibility fields because we're using randomly-generated
|
|
// layers.
|
|
|
|
return args
|
|
}
|
|
|
|
func testManifestAPIManifestList(t *testing.T, env *testEnv, args manifestArgs) {
|
|
imageName := args.imageName
|
|
tag := "manifestlisttag"
|
|
|
|
tagRef, _ := reference.WithTag(imageName, tag)
|
|
manifestURL, err := env.builder.BuildManifestURL(tagRef)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting manifest url: %v", err)
|
|
}
|
|
|
|
// --------------------------------
|
|
// Attempt to push manifest list that refers to an unknown manifest
|
|
manifestList := &manifestlist.ManifestList{
|
|
Versioned: manifest.Versioned{
|
|
SchemaVersion: 2,
|
|
MediaType: manifestlist.MediaTypeManifestList,
|
|
},
|
|
Manifests: []manifestlist.ManifestDescriptor{
|
|
{
|
|
Descriptor: distribution.Descriptor{
|
|
Digest: "sha256:1a9ec845ee94c202b2d5da74a24f0ed2058318bfa9879fa541efaecba272e86b",
|
|
Size: 3253,
|
|
MediaType: schema2.MediaTypeManifest,
|
|
},
|
|
Platform: manifestlist.PlatformSpec{
|
|
Architecture: "amd64",
|
|
OS: "linux",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
resp := putManifest(t, "putting missing manifest manifestlist", manifestURL, manifestlist.MediaTypeManifestList, manifestList)
|
|
defer resp.Body.Close()
|
|
checkResponse(t, "putting missing manifest manifestlist", resp, http.StatusBadRequest)
|
|
_, p, counts := checkBodyHasErrorCodes(t, "putting missing manifest manifestlist", resp, v2.ErrorCodeManifestBlobUnknown)
|
|
|
|
expectedCounts := map[errcode.ErrorCode]int{
|
|
v2.ErrorCodeManifestBlobUnknown: 1,
|
|
}
|
|
|
|
if !reflect.DeepEqual(counts, expectedCounts) {
|
|
t.Fatalf("unexpected number of error codes encountered: %v\n!=\n%v\n---\n%s", counts, expectedCounts, string(p))
|
|
}
|
|
|
|
// -------------------
|
|
// Push a manifest list that references an actual manifest
|
|
manifestList.Manifests[0].Digest = args.dgst
|
|
deserializedManifestList, err := manifestlist.FromDescriptors(manifestList.Manifests)
|
|
if err != nil {
|
|
t.Fatalf("could not create DeserializedManifestList: %v", err)
|
|
}
|
|
_, canonical, err := deserializedManifestList.Payload()
|
|
if err != nil {
|
|
t.Fatalf("could not get manifest list payload: %v", err)
|
|
}
|
|
dgst := digest.FromBytes(canonical)
|
|
|
|
digestRef, _ := reference.WithDigest(imageName, dgst)
|
|
manifestDigestURL, err := env.builder.BuildManifestURL(digestRef)
|
|
checkErr(t, err, "building manifest url")
|
|
|
|
resp = putManifest(t, "putting manifest list no error", manifestURL, manifestlist.MediaTypeManifestList, deserializedManifestList)
|
|
checkResponse(t, "putting manifest list no error", resp, http.StatusCreated)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{manifestDigestURL},
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
// --------------------
|
|
// Push by digest -- should get same result
|
|
resp = putManifest(t, "putting manifest list by digest", manifestDigestURL, manifestlist.MediaTypeManifestList, deserializedManifestList)
|
|
checkResponse(t, "putting manifest list by digest", resp, http.StatusCreated)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{manifestDigestURL},
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
// ------------------
|
|
// Fetch by tag name
|
|
req, err := http.NewRequest(http.MethodGet, manifestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
// multiple headers in mixed list format to ensure we parse correctly server-side
|
|
req.Header.Set("Accept", fmt.Sprintf(` %s ; q=0.8 , %s ; q=0.5 `, manifestlist.MediaTypeManifestList, schema1.MediaTypeSignedManifest)) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
req.Header.Add("Accept", schema2.MediaTypeManifest)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching manifest list: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "fetching uploaded manifest list", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
"ETag": []string{fmt.Sprintf(`"%s"`, dgst)},
|
|
})
|
|
|
|
var fetchedManifestList manifestlist.DeserializedManifestList
|
|
dec := json.NewDecoder(resp.Body)
|
|
|
|
if err := dec.Decode(&fetchedManifestList); err != nil {
|
|
t.Fatalf("error decoding fetched manifest list: %v", err)
|
|
}
|
|
|
|
_, fetchedCanonical, err := fetchedManifestList.Payload()
|
|
if err != nil {
|
|
t.Fatalf("error getting manifest list payload: %v", err)
|
|
}
|
|
|
|
if !bytes.Equal(fetchedCanonical, canonical) {
|
|
t.Fatalf("manifest lists do not match")
|
|
}
|
|
|
|
// ---------------
|
|
// Fetch by digest
|
|
req, err = http.NewRequest(http.MethodGet, manifestDigestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("Accept", manifestlist.MediaTypeManifestList)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
checkErr(t, err, "fetching manifest list by digest")
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "fetching uploaded manifest list", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
"ETag": []string{fmt.Sprintf(`"%s"`, dgst)},
|
|
})
|
|
|
|
var fetchedManifestListByDigest manifestlist.DeserializedManifestList
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err := dec.Decode(&fetchedManifestListByDigest); err != nil {
|
|
t.Fatalf("error decoding fetched manifest: %v", err)
|
|
}
|
|
|
|
_, fetchedCanonical, err = fetchedManifestListByDigest.Payload()
|
|
if err != nil {
|
|
t.Fatalf("error getting manifest list payload: %v", err)
|
|
}
|
|
|
|
if !bytes.Equal(fetchedCanonical, canonical) {
|
|
t.Fatalf("manifests do not match")
|
|
}
|
|
|
|
// Get by name with etag, gives 304
|
|
etag := resp.Header.Get("Etag")
|
|
req, err = http.NewRequest(http.MethodGet, manifestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("If-None-Match", etag)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching manifest by name with etag", resp, http.StatusNotModified)
|
|
|
|
// Get by digest with etag, gives 304
|
|
req, err = http.NewRequest(http.MethodGet, manifestDigestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
req.Header.Set("If-None-Match", etag)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Error constructing request: %s", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching manifest by dgst with etag", resp, http.StatusNotModified)
|
|
|
|
// ------------------
|
|
// Fetch as a schema1 manifest
|
|
resp, err = http.Get(manifestURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching manifest list as schema1: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
manifestBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("error reading response body: %v", err)
|
|
}
|
|
|
|
checkResponse(t, "fetching uploaded manifest list as schema1", resp, http.StatusOK)
|
|
|
|
m, desc, err := distribution.UnmarshalManifest(schema1.MediaTypeManifest, manifestBytes) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatalf("unexpected error unmarshalling manifest: %v", err)
|
|
}
|
|
|
|
fetchedSchema1Manifest, ok := m.(*schema1.SignedManifest) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if !ok {
|
|
t.Fatalf("expecting schema1 manifest")
|
|
}
|
|
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{desc.Digest.String()},
|
|
"ETag": []string{fmt.Sprintf(`"%s"`, desc.Digest)},
|
|
})
|
|
|
|
if fetchedSchema1Manifest.Manifest.SchemaVersion != 1 {
|
|
t.Fatal("wrong schema version")
|
|
}
|
|
if fetchedSchema1Manifest.Architecture != "amd64" {
|
|
t.Fatal("wrong architecture")
|
|
}
|
|
if fetchedSchema1Manifest.Name != imageName.Name() {
|
|
t.Fatal("wrong image name")
|
|
}
|
|
if fetchedSchema1Manifest.Tag != tag {
|
|
t.Fatal("wrong tag")
|
|
}
|
|
if len(fetchedSchema1Manifest.FSLayers) != 2 {
|
|
t.Fatal("wrong number of FSLayers")
|
|
}
|
|
layers := args.manifest.(*schema2.DeserializedManifest).Layers
|
|
for i := range layers {
|
|
if fetchedSchema1Manifest.FSLayers[i].BlobSum != layers[len(layers)-i-1].Digest {
|
|
t.Fatalf("blob digest mismatch in schema1 manifest for layer %d", i)
|
|
}
|
|
}
|
|
if len(fetchedSchema1Manifest.History) != 2 {
|
|
t.Fatal("wrong number of History entries")
|
|
}
|
|
|
|
// Don't check V1Compatibility fields because we're using randomly-generated
|
|
// layers.
|
|
}
|
|
|
|
func testManifestDelete(t *testing.T, env *testEnv, args manifestArgs) {
|
|
imageName := args.imageName
|
|
dgst := args.dgst
|
|
manifest := args.manifest
|
|
|
|
ref, _ := reference.WithDigest(imageName, dgst)
|
|
manifestDigestURL, _ := env.builder.BuildManifestURL(ref)
|
|
// ---------------
|
|
// Delete by digest
|
|
resp, err := httpDelete(manifestDigestURL)
|
|
checkErr(t, err, "deleting manifest by digest")
|
|
|
|
checkResponse(t, "deleting manifest", resp, http.StatusAccepted)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Content-Length": []string{"0"},
|
|
})
|
|
|
|
// ---------------
|
|
// Attempt to fetch deleted manifest
|
|
resp, err = http.Get(manifestDigestURL)
|
|
checkErr(t, err, "fetching deleted manifest by digest")
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "fetching deleted manifest", resp, http.StatusNotFound)
|
|
|
|
// ---------------
|
|
// Delete already deleted manifest by digest
|
|
resp, err = httpDelete(manifestDigestURL)
|
|
checkErr(t, err, "re-deleting manifest by digest")
|
|
|
|
checkResponse(t, "re-deleting manifest", resp, http.StatusNotFound)
|
|
|
|
// --------------------
|
|
// Re-upload manifest by digest
|
|
resp = putManifest(t, "putting manifest", manifestDigestURL, args.mediaType, manifest)
|
|
checkResponse(t, "putting manifest", resp, http.StatusCreated)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{manifestDigestURL},
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
// ---------------
|
|
// Attempt to fetch re-uploaded deleted digest
|
|
resp, err = http.Get(manifestDigestURL)
|
|
checkErr(t, err, "fetching re-uploaded manifest by digest")
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "fetching re-uploaded manifest", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
// ---------------
|
|
// Attempt to delete an unknown manifest
|
|
unknownDigest := digest.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
|
unknownRef, _ := reference.WithDigest(imageName, unknownDigest)
|
|
unknownManifestDigestURL, err := env.builder.BuildManifestURL(unknownRef)
|
|
checkErr(t, err, "building unknown manifest url")
|
|
|
|
resp, err = httpDelete(unknownManifestDigestURL)
|
|
checkErr(t, err, "delting unknown manifest by digest")
|
|
checkResponse(t, "fetching deleted manifest", resp, http.StatusNotFound)
|
|
|
|
// --------------------
|
|
// Upload manifest by tag
|
|
tag := "atag"
|
|
tagRef, _ := reference.WithTag(imageName, tag)
|
|
manifestTagURL, _ := env.builder.BuildManifestURL(tagRef)
|
|
resp = putManifest(t, "putting manifest by tag", manifestTagURL, args.mediaType, manifest)
|
|
checkResponse(t, "putting manifest by tag", resp, http.StatusCreated)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{manifestDigestURL},
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
tagsURL, err := env.builder.BuildTagsURL(imageName)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building tags url: %v", err)
|
|
}
|
|
|
|
// Ensure that the tag is listed.
|
|
resp, err = http.Get(tagsURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting unknown tags: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
dec := json.NewDecoder(resp.Body)
|
|
var tagsResponse tagsAPIResponse
|
|
if err := dec.Decode(&tagsResponse); err != nil {
|
|
t.Fatalf("unexpected error decoding error response: %v", err)
|
|
}
|
|
|
|
if tagsResponse.Name != imageName.Name() {
|
|
t.Fatalf("tags name should match image name: %v != %v", tagsResponse.Name, imageName)
|
|
}
|
|
|
|
if len(tagsResponse.Tags) != 1 {
|
|
t.Fatalf("expected some tags in response: %v", tagsResponse.Tags)
|
|
}
|
|
|
|
if tagsResponse.Tags[0] != tag {
|
|
t.Fatalf("tag not as expected: %q != %q", tagsResponse.Tags[0], tag)
|
|
}
|
|
|
|
// ---------------
|
|
// Delete by digest
|
|
resp, err = httpDelete(manifestDigestURL)
|
|
checkErr(t, err, "deleting manifest by digest")
|
|
|
|
checkResponse(t, "deleting manifest with tag", resp, http.StatusAccepted)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Content-Length": []string{"0"},
|
|
})
|
|
|
|
// Ensure that the tag is not listed.
|
|
resp, err = http.Get(tagsURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting unknown tags: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
dec = json.NewDecoder(resp.Body)
|
|
if err := dec.Decode(&tagsResponse); err != nil {
|
|
t.Fatalf("unexpected error decoding error response: %v", err)
|
|
}
|
|
|
|
if tagsResponse.Name != imageName.Name() {
|
|
t.Fatalf("tags name should match image name: %v != %v", tagsResponse.Name, imageName)
|
|
}
|
|
|
|
if len(tagsResponse.Tags) != 0 {
|
|
t.Fatalf("expected 0 tags in response: %v", tagsResponse.Tags)
|
|
}
|
|
}
|
|
|
|
type testEnv struct {
|
|
pk libtrust.PrivateKey
|
|
ctx context.Context
|
|
config configuration.Configuration
|
|
app *App
|
|
server *httptest.Server
|
|
builder *v2.URLBuilder
|
|
}
|
|
|
|
func newTestEnvMirror(t *testing.T, deleteEnabled bool) *testEnv {
|
|
upstreamEnv := newTestEnv(t, deleteEnabled)
|
|
config := configuration.Configuration{
|
|
Storage: configuration.Storage{
|
|
"inmemory": configuration.Parameters{},
|
|
"delete": configuration.Parameters{"enabled": deleteEnabled},
|
|
"maintenance": configuration.Parameters{"uploadpurging": map[interface{}]interface{}{
|
|
"enabled": false,
|
|
}},
|
|
},
|
|
Proxy: configuration.Proxy{
|
|
RemoteURL: upstreamEnv.server.URL,
|
|
},
|
|
Catalog: configuration.Catalog{
|
|
MaxEntries: 5,
|
|
},
|
|
}
|
|
config.Compatibility.Schema1.Enabled = true //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
|
|
return newTestEnvWithConfig(t, &config)
|
|
}
|
|
|
|
func newTestEnv(t *testing.T, deleteEnabled bool) *testEnv {
|
|
config := configuration.Configuration{
|
|
Storage: configuration.Storage{
|
|
"inmemory": configuration.Parameters{},
|
|
"delete": configuration.Parameters{"enabled": deleteEnabled},
|
|
"maintenance": configuration.Parameters{"uploadpurging": map[interface{}]interface{}{
|
|
"enabled": false,
|
|
}},
|
|
},
|
|
Catalog: configuration.Catalog{
|
|
MaxEntries: 5,
|
|
},
|
|
}
|
|
|
|
config.Compatibility.Schema1.Enabled = true //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
config.HTTP.Headers = headerConfig
|
|
|
|
return newTestEnvWithConfig(t, &config)
|
|
}
|
|
|
|
func newTestEnvWithConfig(t *testing.T, config *configuration.Configuration) *testEnv {
|
|
ctx := context.Background()
|
|
|
|
app := NewApp(ctx, config)
|
|
server := httptest.NewServer(handlers.CombinedLoggingHandler(os.Stderr, app))
|
|
builder, err := v2.NewURLBuilderFromString(server.URL+config.HTTP.Prefix, false)
|
|
if err != nil {
|
|
t.Fatalf("error creating url builder: %v", err)
|
|
}
|
|
|
|
pk, err := libtrust.GenerateECP256PrivateKey()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error generating private key: %v", err)
|
|
}
|
|
|
|
return &testEnv{
|
|
pk: pk,
|
|
ctx: ctx,
|
|
config: *config,
|
|
app: app,
|
|
server: server,
|
|
builder: builder,
|
|
}
|
|
}
|
|
|
|
func (t *testEnv) Shutdown() {
|
|
t.server.CloseClientConnections()
|
|
t.server.Close()
|
|
}
|
|
|
|
func putManifest(t *testing.T, msg, url, contentType string, v interface{}) *http.Response {
|
|
var body []byte
|
|
|
|
switch m := v.(type) {
|
|
case *schema1.SignedManifest: //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
_, pl, err := m.Payload() //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatalf("error getting payload: %v", err)
|
|
}
|
|
body = pl
|
|
case *manifestlist.DeserializedManifestList:
|
|
_, pl, err := m.Payload()
|
|
if err != nil {
|
|
t.Fatalf("error getting payload: %v", err)
|
|
}
|
|
body = pl
|
|
default:
|
|
var err error
|
|
body, err = json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error marshaling %v: %v", v, err)
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("error creating request for %s: %v", msg, err)
|
|
}
|
|
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("error doing put request while %s: %v", msg, err)
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
func startPushLayer(t *testing.T, env *testEnv, name reference.Named) (location string, uuid string) {
|
|
layerUploadURL, err := env.builder.BuildBlobUploadURL(name)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building layer upload url: %v", err)
|
|
}
|
|
|
|
u, err := url.Parse(layerUploadURL)
|
|
if err != nil {
|
|
t.Fatalf("error parsing layer upload URL: %v", err)
|
|
}
|
|
|
|
base, err := url.Parse(env.server.URL)
|
|
if err != nil {
|
|
t.Fatalf("error parsing server URL: %v", err)
|
|
}
|
|
|
|
layerUploadURL = base.ResolveReference(u).String()
|
|
resp, err := http.Post(layerUploadURL, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error starting layer push: %v", err)
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, fmt.Sprintf("pushing starting layer push %v", name.String()), resp, http.StatusAccepted)
|
|
|
|
u, err = url.Parse(resp.Header.Get("Location"))
|
|
if err != nil {
|
|
t.Fatalf("error parsing location header: %v", err)
|
|
}
|
|
|
|
uuid = path.Base(u.Path)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{"*"},
|
|
"Content-Length": []string{"0"},
|
|
"Docker-Upload-UUID": []string{uuid},
|
|
})
|
|
|
|
return resp.Header.Get("Location"), uuid
|
|
}
|
|
|
|
// doPushLayer pushes the layer content returning the url on success returning
|
|
// the response. If you're only expecting a successful response, use pushLayer.
|
|
func doPushLayer(t *testing.T, ub *v2.URLBuilder, name reference.Named, dgst digest.Digest, uploadURLBase string, body io.Reader) (*http.Response, error) {
|
|
u, err := url.Parse(uploadURLBase)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing pushLayer url: %v", err)
|
|
}
|
|
|
|
u.RawQuery = url.Values{
|
|
"_state": u.Query()["_state"],
|
|
"digest": []string{dgst.String()},
|
|
}.Encode()
|
|
|
|
uploadURL := u.String()
|
|
|
|
// Just do a monolithic upload
|
|
req, err := http.NewRequest(http.MethodPut, uploadURL, body)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error creating new request: %v", err)
|
|
}
|
|
|
|
return http.DefaultClient.Do(req)
|
|
}
|
|
|
|
// pushLayer pushes the layer content returning the url on success.
|
|
func pushLayer(t *testing.T, ub *v2.URLBuilder, name reference.Named, dgst digest.Digest, uploadURLBase string, body io.Reader) string {
|
|
digester := digest.Canonical.Digester()
|
|
|
|
resp, err := doPushLayer(t, ub, name, dgst, uploadURLBase, io.TeeReader(body, digester.Hash()))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error doing push layer request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "putting monolithic chunk", resp, http.StatusCreated)
|
|
|
|
if err != nil {
|
|
t.Fatalf("error generating sha256 digest of body")
|
|
}
|
|
|
|
sha256Dgst := digester.Digest()
|
|
|
|
ref, _ := reference.WithDigest(name, sha256Dgst)
|
|
expectedLayerURL, err := ub.BuildBlobURL(ref)
|
|
if err != nil {
|
|
t.Fatalf("error building expected layer url: %v", err)
|
|
}
|
|
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{expectedLayerURL},
|
|
"Content-Length": []string{"0"},
|
|
"Docker-Content-Digest": []string{sha256Dgst.String()},
|
|
})
|
|
|
|
return resp.Header.Get("Location")
|
|
}
|
|
|
|
func getUploadStatus(location string) (string, int64, error) {
|
|
req, err := http.NewRequest(http.MethodGet, location, nil)
|
|
if err != nil {
|
|
return location, -1, err
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return location, -1, err
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
_, end, err := parseContentRange(resp.Header.Get("Range"))
|
|
if err != nil {
|
|
return location, -1, err
|
|
}
|
|
|
|
return resp.Header.Get("Location"), end, nil
|
|
}
|
|
|
|
func finishUpload(t *testing.T, ub *v2.URLBuilder, name reference.Named, uploadURLBase string, dgst digest.Digest) string {
|
|
resp, err := doPushLayer(t, ub, name, dgst, uploadURLBase, nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error doing push layer request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "putting monolithic chunk", resp, http.StatusCreated)
|
|
|
|
ref, _ := reference.WithDigest(name, dgst)
|
|
expectedLayerURL, err := ub.BuildBlobURL(ref)
|
|
if err != nil {
|
|
t.Fatalf("error building expected layer url: %v", err)
|
|
}
|
|
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{expectedLayerURL},
|
|
"Content-Length": []string{"0"},
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
return resp.Header.Get("Location")
|
|
}
|
|
|
|
type chunkOptions struct {
|
|
// Content-Range header to set when pushing chunks
|
|
contentRange string
|
|
}
|
|
|
|
func doPushChunk(t *testing.T, uploadURLBase string, body io.Reader, options chunkOptions) (*http.Response, error) {
|
|
u, err := url.Parse(uploadURLBase)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing pushLayer url: %v", err)
|
|
}
|
|
|
|
u.RawQuery = url.Values{
|
|
"_state": u.Query()["_state"],
|
|
}.Encode()
|
|
|
|
uploadURL := u.String()
|
|
|
|
req, err := http.NewRequest(http.MethodPatch, uploadURL, body)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error creating new request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
if options.contentRange != "" {
|
|
req.Header.Set("Content-Range", options.contentRange)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
return resp, err
|
|
}
|
|
|
|
func pushChunk(t *testing.T, ub *v2.URLBuilder, name reference.Named, uploadURLBase string, body io.Reader, length int64) (string, digest.Digest) {
|
|
digester := digest.Canonical.Digester()
|
|
|
|
resp, err := doPushChunk(t, uploadURLBase, io.TeeReader(body, digester.Hash()), chunkOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error doing push layer request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, "putting chunk", resp, http.StatusAccepted)
|
|
|
|
if err != nil {
|
|
t.Fatalf("error generating sha256 digest of body")
|
|
}
|
|
|
|
checkHeaders(t, resp, http.Header{
|
|
"Range": []string{fmt.Sprintf("0-%d", length-1)},
|
|
"Content-Length": []string{"0"},
|
|
})
|
|
|
|
return resp.Header.Get("Location"), digester.Digest()
|
|
}
|
|
|
|
func checkResponse(t *testing.T, msg string, resp *http.Response, expectedStatus int) {
|
|
if resp.StatusCode != expectedStatus {
|
|
t.Logf("unexpected status %s: %v != %v", msg, resp.StatusCode, expectedStatus)
|
|
maybeDumpResponse(t, resp)
|
|
t.FailNow()
|
|
}
|
|
|
|
// We expect the headers included in the configuration, unless the
|
|
// status code is 405 (Method Not Allowed), which means the handler
|
|
// doesn't even get called.
|
|
if resp.StatusCode != 405 && !reflect.DeepEqual(resp.Header["X-Content-Type-Options"], []string{"nosniff"}) {
|
|
t.Logf("missing or incorrect header X-Content-Type-Options %s", msg)
|
|
maybeDumpResponse(t, resp)
|
|
|
|
t.FailNow()
|
|
}
|
|
}
|
|
|
|
// checkBodyHasErrorCodes ensures the body is an error body and has the
|
|
// expected error codes, returning the error structure, the json slice and a
|
|
// count of the errors by code.
|
|
func checkBodyHasErrorCodes(t *testing.T, msg string, resp *http.Response, errorCodes ...errcode.ErrorCode) (errcode.Errors, []byte, map[errcode.ErrorCode]int) {
|
|
p, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error reading body %s: %v", msg, err)
|
|
}
|
|
|
|
var errs errcode.Errors
|
|
if err := json.Unmarshal(p, &errs); err != nil {
|
|
t.Fatalf("unexpected error decoding error response: %v", err)
|
|
}
|
|
|
|
if len(errs) == 0 {
|
|
t.Fatalf("expected errors in response")
|
|
}
|
|
|
|
// TODO(stevvooe): Shoot. The error setup is not working out. The content-
|
|
// type headers are being set after writing the status code.
|
|
// if resp.Header.Get("Content-Type") != "application/json" {
|
|
// t.Fatalf("unexpected content type: %v != 'application/json'",
|
|
// resp.Header.Get("Content-Type"))
|
|
// }
|
|
|
|
expected := map[errcode.ErrorCode]struct{}{}
|
|
counts := map[errcode.ErrorCode]int{}
|
|
|
|
// Initialize map with zeros for expected
|
|
for _, code := range errorCodes {
|
|
expected[code] = struct{}{}
|
|
counts[code] = 0
|
|
}
|
|
|
|
for _, e := range errs {
|
|
err, ok := e.(errcode.ErrorCoder)
|
|
if !ok {
|
|
t.Fatalf("not an ErrorCoder: %#v", e)
|
|
}
|
|
if _, ok := expected[err.ErrorCode()]; !ok {
|
|
t.Fatalf("unexpected error code %v encountered during %s: %s ", err.ErrorCode(), msg, string(p))
|
|
}
|
|
counts[err.ErrorCode()]++
|
|
}
|
|
|
|
// Ensure that counts of expected errors were all non-zero
|
|
for code := range expected {
|
|
if counts[code] == 0 {
|
|
t.Fatalf("expected error code %v not encountered during %s: %s", code, msg, string(p))
|
|
}
|
|
}
|
|
|
|
return errs, p, counts
|
|
}
|
|
|
|
func maybeDumpResponse(t *testing.T, resp *http.Response) {
|
|
if d, err := httputil.DumpResponse(resp, true); err != nil {
|
|
t.Logf("error dumping response: %v", err)
|
|
} else {
|
|
t.Logf("response:\n%s", string(d))
|
|
}
|
|
}
|
|
|
|
// matchHeaders checks that the response has at least the headers. If not, the
|
|
// test will fail. If a passed in header value is "*", any non-zero value will
|
|
// suffice as a match.
|
|
func checkHeaders(t *testing.T, resp *http.Response, headers http.Header) {
|
|
for k, vs := range headers {
|
|
if resp.Header.Get(k) == "" {
|
|
t.Fatalf("response missing header %q", k)
|
|
}
|
|
|
|
for _, v := range vs {
|
|
if v == "*" {
|
|
// Just ensure there is some value.
|
|
if len(resp.Header[http.CanonicalHeaderKey(k)]) > 0 {
|
|
continue
|
|
}
|
|
}
|
|
|
|
for _, hv := range resp.Header[http.CanonicalHeaderKey(k)] {
|
|
if hv != v {
|
|
t.Fatalf("%+v %v header value not matched in response: %q != %q", resp.Header, k, hv, v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkErr(t *testing.T, err error, msg string) {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error %s: %v", msg, err)
|
|
}
|
|
}
|
|
|
|
func createRepository(env *testEnv, t *testing.T, imageName string, tag string) digest.Digest {
|
|
imageNameRef, err := reference.WithName(imageName)
|
|
if err != nil {
|
|
t.Fatalf("unable to parse reference: %v", err)
|
|
}
|
|
|
|
unsignedManifest := &schema1.Manifest{ //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
Versioned: manifest.Versioned{
|
|
SchemaVersion: 1,
|
|
},
|
|
Name: imageName,
|
|
Tag: tag,
|
|
FSLayers: []schema1.FSLayer{ //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
{
|
|
BlobSum: "asdf",
|
|
},
|
|
},
|
|
History: []schema1.History{ //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
{
|
|
V1Compatibility: "",
|
|
},
|
|
},
|
|
}
|
|
|
|
// Push 2 random layers
|
|
expectedLayers := make(map[digest.Digest]io.ReadSeeker)
|
|
|
|
for i := range unsignedManifest.FSLayers {
|
|
rs, dgst, err := testutil.CreateRandomTarFile()
|
|
if err != nil {
|
|
t.Fatalf("error creating random layer %d: %v", i, err)
|
|
}
|
|
|
|
expectedLayers[dgst] = rs
|
|
unsignedManifest.FSLayers[i].BlobSum = dgst
|
|
uploadURLBase, _ := startPushLayer(t, env, imageNameRef)
|
|
pushLayer(t, env.builder, imageNameRef, dgst, uploadURLBase, rs)
|
|
}
|
|
|
|
signedManifest, err := schema1.Sign(unsignedManifest, env.pk) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatalf("unexpected error signing manifest: %v", err)
|
|
}
|
|
|
|
dgst := digest.FromBytes(signedManifest.Canonical)
|
|
|
|
// Create this repository by tag to ensure the tag mapping is made in the registry
|
|
tagRef, _ := reference.WithTag(imageNameRef, tag)
|
|
manifestDigestURL, err := env.builder.BuildManifestURL(tagRef)
|
|
checkErr(t, err, "building manifest url")
|
|
|
|
digestRef, _ := reference.WithDigest(imageNameRef, dgst)
|
|
location, err := env.builder.BuildManifestURL(digestRef)
|
|
checkErr(t, err, "building location URL")
|
|
|
|
resp := putManifest(t, "putting signed manifest", manifestDigestURL, "", signedManifest)
|
|
checkResponse(t, "putting signed manifest", resp, http.StatusCreated)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Location": []string{location},
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
return dgst
|
|
}
|
|
|
|
// Test mutation operations on a registry configured as a cache. Ensure that they return
|
|
// appropriate errors.
|
|
func TestRegistryAsCacheMutationAPIs(t *testing.T) {
|
|
deleteEnabled := true
|
|
env := newTestEnvMirror(t, deleteEnabled)
|
|
defer env.Shutdown()
|
|
|
|
imageName, _ := reference.WithName("foo/bar")
|
|
tag := "latest"
|
|
tagRef, _ := reference.WithTag(imageName, tag)
|
|
manifestURL, err := env.builder.BuildManifestURL(tagRef)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building base url: %v", err)
|
|
}
|
|
|
|
// Manifest upload
|
|
m := &schema1.Manifest{ //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
Versioned: manifest.Versioned{
|
|
SchemaVersion: 1,
|
|
},
|
|
Name: imageName.Name(),
|
|
Tag: tag,
|
|
FSLayers: []schema1.FSLayer{}, //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
History: []schema1.History{}, //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
}
|
|
|
|
sm, err := schema1.Sign(m, env.pk) //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
if err != nil {
|
|
t.Fatalf("error signing manifest: %v", err)
|
|
}
|
|
|
|
resp := putManifest(t, "putting unsigned manifest", manifestURL, "", sm)
|
|
checkResponse(t, "putting signed manifest to cache", resp, errcode.ErrorCodeUnsupported.Descriptor().HTTPStatusCode)
|
|
|
|
// Manifest Delete
|
|
resp, _ = httpDelete(manifestURL)
|
|
checkResponse(t, "deleting signed manifest from cache", resp, errcode.ErrorCodeUnsupported.Descriptor().HTTPStatusCode)
|
|
|
|
// Blob upload initialization
|
|
layerUploadURL, err := env.builder.BuildBlobUploadURL(imageName)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error building layer upload url: %v", err)
|
|
}
|
|
|
|
resp, err = http.Post(layerUploadURL, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error starting layer push: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checkResponse(t, fmt.Sprintf("starting layer push to cache %v", imageName), resp, errcode.ErrorCodeUnsupported.Descriptor().HTTPStatusCode)
|
|
|
|
// Blob Delete
|
|
ref, _ := reference.WithDigest(imageName, digestSha256EmptyTar)
|
|
blobURL, _ := env.builder.BuildBlobURL(ref)
|
|
resp, _ = httpDelete(blobURL)
|
|
checkResponse(t, "deleting blob from cache", resp, errcode.ErrorCodeUnsupported.Descriptor().HTTPStatusCode)
|
|
}
|
|
|
|
func TestProxyManifestGetByTag(t *testing.T) {
|
|
truthConfig := configuration.Configuration{
|
|
Storage: configuration.Storage{
|
|
"inmemory": configuration.Parameters{},
|
|
"maintenance": configuration.Parameters{"uploadpurging": map[interface{}]interface{}{
|
|
"enabled": false,
|
|
}},
|
|
},
|
|
}
|
|
truthConfig.Compatibility.Schema1.Enabled = true //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
truthConfig.HTTP.Headers = headerConfig
|
|
|
|
imageName, _ := reference.WithName("foo/bar")
|
|
tag := "latest"
|
|
|
|
truthEnv := newTestEnvWithConfig(t, &truthConfig)
|
|
defer truthEnv.Shutdown()
|
|
// create a repository in the truth registry
|
|
dgst := createRepository(truthEnv, t, imageName.Name(), tag)
|
|
|
|
proxyConfig := configuration.Configuration{
|
|
Storage: configuration.Storage{
|
|
"inmemory": configuration.Parameters{},
|
|
},
|
|
Proxy: configuration.Proxy{
|
|
RemoteURL: truthEnv.server.URL,
|
|
},
|
|
}
|
|
proxyConfig.Compatibility.Schema1.Enabled = true //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility.
|
|
proxyConfig.HTTP.Headers = headerConfig
|
|
|
|
proxyEnv := newTestEnvWithConfig(t, &proxyConfig)
|
|
defer proxyEnv.Shutdown()
|
|
|
|
digestRef, _ := reference.WithDigest(imageName, dgst)
|
|
manifestDigestURL, err := proxyEnv.builder.BuildManifestURL(digestRef)
|
|
checkErr(t, err, "building manifest url")
|
|
|
|
resp, err := http.Get(manifestDigestURL)
|
|
checkErr(t, err, "fetching manifest from proxy by digest")
|
|
defer resp.Body.Close()
|
|
|
|
tagRef, _ := reference.WithTag(imageName, tag)
|
|
manifestTagURL, err := proxyEnv.builder.BuildManifestURL(tagRef)
|
|
checkErr(t, err, "building manifest url")
|
|
|
|
resp, err = http.Get(manifestTagURL)
|
|
checkErr(t, err, "fetching manifest from proxy by tag (error check 1)")
|
|
defer resp.Body.Close()
|
|
checkResponse(t, "fetching manifest from proxy by tag (response check 1)", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{dgst.String()},
|
|
})
|
|
|
|
// Create another manifest in the remote with the same image/tag pair
|
|
newDigest := createRepository(truthEnv, t, imageName.Name(), tag)
|
|
if dgst == newDigest {
|
|
t.Fatalf("non-random test data")
|
|
}
|
|
|
|
// fetch it with the same proxy URL as before. Ensure the updated content is at the same tag
|
|
resp, err = http.Get(manifestTagURL)
|
|
checkErr(t, err, "fetching manifest from proxy by tag (error check 2)")
|
|
defer resp.Body.Close()
|
|
checkResponse(t, "fetching manifest from proxy by tag (response check 2)", resp, http.StatusOK)
|
|
checkHeaders(t, resp, http.Header{
|
|
"Docker-Content-Digest": []string{newDigest.String()},
|
|
})
|
|
}
|