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:
Ramkumar Chinchani
2022-08-30 22:12:10 +00:00
committed by Ramkumar Chinchani
parent 7912b6a3fb
commit 90c8390c29
10 changed files with 723 additions and 27 deletions
+161
View File
@@ -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
View File
@@ -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