mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 04:48:26 +08:00
routes: support resumable pull
Some embedded clients use the "Range" header for blob pulls in order to resume downloads. Signed-off-by: Ramkumar Chinchani <rchincha@cisco.com>
This commit is contained in:
committed by
Ramkumar Chinchani
parent
7912b6a3fb
commit
90c8390c29
@@ -5463,6 +5463,167 @@ func TestManifestImageIndex(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPullRange(t *testing.T) {
|
||||
Convey("Make a new controller", t, func() {
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
conf := config.New()
|
||||
conf.HTTP.Port = port
|
||||
|
||||
ctlr := api.NewController(conf)
|
||||
dir := t.TempDir()
|
||||
ctlr.Config.Storage.RootDirectory = dir
|
||||
|
||||
go startServer(ctlr)
|
||||
defer stopServer(ctlr)
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
||||
// create a blob/layer
|
||||
resp, err := resty.R().Post(baseURL + "/v2/index/blobs/uploads/")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusAccepted)
|
||||
loc := test.Location(baseURL, resp)
|
||||
So(loc, ShouldNotBeEmpty)
|
||||
|
||||
// since we are not specifying any prefix i.e provided in config while starting server,
|
||||
// so it should store index1 to global root dir
|
||||
_, err = os.Stat(path.Join(dir, "index"))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
resp, err = resty.R().Get(loc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusNoContent)
|
||||
content := []byte("0123456789")
|
||||
digest := godigest.FromBytes(content)
|
||||
So(digest, ShouldNotBeNil)
|
||||
// monolithic blob upload: success
|
||||
resp, err = resty.R().SetQueryParam("digest", digest.String()).
|
||||
SetHeader("Content-Type", "application/octet-stream").SetBody(content).Put(loc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
|
||||
blobLoc := resp.Header().Get("Location")
|
||||
So(blobLoc, ShouldNotBeEmpty)
|
||||
So(resp.Header().Get("Content-Length"), ShouldEqual, "0")
|
||||
So(resp.Header().Get(constants.DistContentDigestKey), ShouldNotBeEmpty)
|
||||
blobLoc = baseURL + blobLoc
|
||||
|
||||
Convey("Range is supported using 'bytes'", func() {
|
||||
resp, err = resty.R().Head(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.Header().Get("Accept-Ranges"), ShouldEqual, "bytes")
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
})
|
||||
|
||||
Convey("Get a range of bytes", func() {
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=0-").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent)
|
||||
So(resp.Header().Get("Content-Length"), ShouldEqual, fmt.Sprintf("%d", len(content)))
|
||||
So(resp.Body(), ShouldResemble, content)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=0-100").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent)
|
||||
So(resp.Header().Get("Content-Length"), ShouldEqual, fmt.Sprintf("%d", len(content)))
|
||||
So(resp.Body(), ShouldResemble, content)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=0-10").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent)
|
||||
So(resp.Header().Get("Content-Length"), ShouldEqual, fmt.Sprintf("%d", len(content)))
|
||||
So(resp.Body(), ShouldResemble, content)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=0-0").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent)
|
||||
So(resp.Header().Get("Content-Length"), ShouldEqual, "1")
|
||||
So(resp.Body(), ShouldResemble, content[0:1])
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=0-1").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent)
|
||||
So(resp.Header().Get("Content-Length"), ShouldEqual, "2")
|
||||
So(resp.Body(), ShouldResemble, content[0:2])
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=2-3").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent)
|
||||
So(resp.Header().Get("Content-Length"), ShouldEqual, "2")
|
||||
So(resp.Body(), ShouldResemble, content[2:4])
|
||||
})
|
||||
|
||||
Convey("Negative cases", func() {
|
||||
resp, err = resty.R().SetHeader("Range", "=0").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "=a").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "=").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "byte=").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "byte=-0").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "byte=0").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "octet=-0").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=-0").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=1-0").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=-1-0").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=-1--0").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=1--2").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=0-a").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=a-10").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
resp, err = resty.R().SetHeader("Range", "bytes=a-b").Get(blobLoc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestInjectInterruptedImageManifest(t *testing.T) {
|
||||
Convey("Make a new controller", t, func() {
|
||||
port := test.GetFreePort()
|
||||
|
||||
+97
-3
@@ -18,6 +18,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -599,10 +600,57 @@ func (rh *RouteHandler) CheckBlob(response http.ResponseWriter, request *http.Re
|
||||
}
|
||||
|
||||
response.Header().Set("Content-Length", fmt.Sprintf("%d", blen))
|
||||
response.Header().Set("Accept-Ranges", "bytes")
|
||||
response.Header().Set(constants.DistContentDigestKey, digest)
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
/* parseRangeHeader validates the "Range" HTTP header and returns the range. */
|
||||
func parseRangeHeader(contentRange string) (int64, int64, error) {
|
||||
/* bytes=<start>- and bytes=<start>-<end> formats are supported */
|
||||
pattern := `bytes=(?P<rangeFrom>\d+)-(?P<rangeTo>\d*$)`
|
||||
|
||||
regex, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return -1, -1, zerr.ErrParsingHTTPHeader
|
||||
}
|
||||
|
||||
match := regex.FindStringSubmatch(contentRange)
|
||||
|
||||
paramsMap := make(map[string]string)
|
||||
|
||||
for i, name := range regex.SubexpNames() {
|
||||
if i > 0 && i <= len(match) {
|
||||
paramsMap[name] = match[i]
|
||||
}
|
||||
}
|
||||
|
||||
var from int64
|
||||
to := int64(-1)
|
||||
|
||||
rangeFrom := paramsMap["rangeFrom"]
|
||||
if rangeFrom == "" {
|
||||
return -1, -1, zerr.ErrParsingHTTPHeader
|
||||
}
|
||||
|
||||
if from, err = strconv.ParseInt(rangeFrom, 10, 64); err != nil {
|
||||
return -1, -1, zerr.ErrParsingHTTPHeader
|
||||
}
|
||||
|
||||
rangeTo := paramsMap["rangeTo"]
|
||||
if rangeTo != "" {
|
||||
if to, err = strconv.ParseInt(rangeTo, 10, 64); err != nil {
|
||||
return -1, -1, zerr.ErrParsingHTTPHeader
|
||||
}
|
||||
|
||||
if to < from {
|
||||
return -1, -1, zerr.ErrParsingHTTPHeader
|
||||
}
|
||||
}
|
||||
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
// GetBlob godoc
|
||||
// @Summary Get image blob/layer
|
||||
// @Description Get an image's blob/layer given a digest
|
||||
@@ -634,7 +682,43 @@ func (rh *RouteHandler) GetBlob(response http.ResponseWriter, request *http.Requ
|
||||
|
||||
mediaType := request.Header.Get("Accept")
|
||||
|
||||
repo, blen, err := imgStore.GetBlob(name, digest, mediaType)
|
||||
var err error
|
||||
|
||||
/* content range is supported for resumbale pulls */
|
||||
partial := false
|
||||
|
||||
var from, to int64
|
||||
|
||||
contentRange := request.Header.Get("Range")
|
||||
|
||||
_, ok = request.Header["Range"]
|
||||
if ok && contentRange == "" {
|
||||
response.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if contentRange != "" {
|
||||
from, to, err = parseRangeHeader(contentRange)
|
||||
if err != nil {
|
||||
response.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
partial = true
|
||||
}
|
||||
|
||||
var repo io.ReadCloser
|
||||
|
||||
var blen, bsize int64
|
||||
|
||||
if partial {
|
||||
repo, blen, bsize, err = imgStore.GetBlobPartial(name, digest, mediaType, from, to)
|
||||
} else {
|
||||
repo, blen, err = imgStore.GetBlob(name, digest, mediaType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, zerr.ErrBadBlobDigest) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
||||
WriteJSON(response,
|
||||
@@ -658,9 +742,19 @@ func (rh *RouteHandler) GetBlob(response http.ResponseWriter, request *http.Requ
|
||||
defer repo.Close()
|
||||
|
||||
response.Header().Set("Content-Length", fmt.Sprintf("%d", blen))
|
||||
response.Header().Set(constants.DistContentDigestKey, digest)
|
||||
|
||||
status := http.StatusOK
|
||||
|
||||
if partial {
|
||||
status = http.StatusPartialContent
|
||||
|
||||
response.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", from, from+blen-1, bsize))
|
||||
} else {
|
||||
response.Header().Set(constants.DistContentDigestKey, digest)
|
||||
}
|
||||
|
||||
// return the blob data
|
||||
WriteDataFromReader(response, http.StatusOK, blen, mediaType, repo, rh.c.Log)
|
||||
WriteDataFromReader(response, status, blen, mediaType, repo, rh.c.Log)
|
||||
}
|
||||
|
||||
// DeleteBlob godoc
|
||||
|
||||
Reference in New Issue
Block a user