Skip to content

Commit d396c2a

Browse files
committed
Add resumable download support via range requests
So we can resume pulls Signed-off-by: Eric Curtin <eric.curtin@docker.com>
1 parent 7471efd commit d396c2a

File tree

3 files changed

+114
-10
lines changed

3 files changed

+114
-10
lines changed

pkg/registry/blobs.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -302,13 +302,19 @@ func (b *blobs) handle(resp http.ResponseWriter, req *http.Request) *regError {
302302
}
303303

304304
if rangeHeader != "" {
305-
start, end := int64(0), int64(0)
305+
start, end := int64(0), size-1
306+
// Try parsing as "bytes=start-end" first
306307
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
307-
return &regError{
308-
Status: http.StatusRequestedRangeNotSatisfiable,
309-
Code: "BLOB_UNKNOWN",
310-
Message: "We don't understand your Range",
308+
// Try parsing as "bytes=start-" (open-ended range)
309+
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-", &start); err != nil {
310+
return &regError{
311+
Status: http.StatusRequestedRangeNotSatisfiable,
312+
Code: "BLOB_UNKNOWN",
313+
Message: "We don't understand your Range",
314+
}
311315
}
316+
// For open-ended range, end is the last byte of the blob
317+
end = size - 1
312318
}
313319

314320
n := (end + 1) - start

pkg/v1/remote/fetcher.go

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,30 +245,96 @@ func (f *fetcher) headManifest(ctx context.Context, ref name.Reference, acceptab
245245
}, nil
246246
}
247247

248+
// contextKey is a type for context keys used in this package
249+
type contextKey string
250+
251+
const resumeOffsetKey contextKey = "resumeOffset"
252+
const resumeOffsetsKey contextKey = "resumeOffsets"
253+
254+
// WithResumeOffset returns a context with the resume offset set for a single blob
255+
func WithResumeOffset(ctx context.Context, offset int64) context.Context {
256+
return context.WithValue(ctx, resumeOffsetKey, offset)
257+
}
258+
259+
// WithResumeOffsets returns a context with resume offsets for multiple blobs (keyed by digest)
260+
func WithResumeOffsets(ctx context.Context, offsets map[string]int64) context.Context {
261+
return context.WithValue(ctx, resumeOffsetsKey, offsets)
262+
}
263+
264+
// getResumeOffset retrieves the resume offset from context for a given digest
265+
func getResumeOffset(ctx context.Context, digest string) int64 {
266+
// First check if there's a specific offset for this digest
267+
if offsets, ok := ctx.Value(resumeOffsetsKey).(map[string]int64); ok {
268+
if offset, found := offsets[digest]; found && offset > 0 {
269+
return offset
270+
}
271+
}
272+
// Fall back to single offset (for fetchBlob)
273+
if offset, ok := ctx.Value(resumeOffsetKey).(int64); ok {
274+
return offset
275+
}
276+
return 0
277+
}
278+
248279
func (f *fetcher) fetchBlob(ctx context.Context, size int64, h v1.Hash) (io.ReadCloser, error) {
249280
u := f.url("blobs", h.String())
250281
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
251282
if err != nil {
252283
return nil, err
253284
}
254285

286+
// Check if we should resume from a specific offset
287+
resumeOffset := getResumeOffset(ctx, h.String())
288+
if resumeOffset > 0 {
289+
// Add Range header to resume download
290+
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
291+
}
292+
255293
resp, err := f.client.Do(req.WithContext(ctx))
256294
if err != nil {
257295
return nil, redact.Error(err)
258296
}
259297

260-
if err := transport.CheckError(resp, http.StatusOK); err != nil {
298+
// Accept both 200 OK (full content) and 206 Partial Content (resumed)
299+
if resumeOffset > 0 {
300+
// If we requested a Range but got 200, the server doesn't support ranges
301+
// We'll have to download from scratch
302+
if resp.StatusCode == http.StatusOK {
303+
// Server doesn't support range requests, will download full content
304+
resumeOffset = 0
305+
}
306+
}
307+
308+
if err := transport.CheckError(resp, http.StatusOK, http.StatusPartialContent); err != nil {
261309
resp.Body.Close()
262310
return nil, err
263311
}
264312

265-
// Do whatever we can.
266-
// If we have an expected size and Content-Length doesn't match, return an error.
267-
// If we don't have an expected size and we do have a Content-Length, use Content-Length.
313+
// For partial content (resumed downloads), we can't verify the hash on the stream
314+
// since we're only getting part of the file. The complete file will be verified
315+
// after all bytes are written to disk.
316+
if resumeOffset > 0 && resp.StatusCode == http.StatusPartialContent {
317+
// Verify Content-Length matches expected remaining size
318+
if hsize := resp.ContentLength; hsize != -1 {
319+
if size != verify.SizeUnknown {
320+
expectedRemaining := size - resumeOffset
321+
if hsize != expectedRemaining {
322+
resp.Body.Close()
323+
return nil, fmt.Errorf("GET %s: Content-Length header %d does not match expected remaining size %d", u.String(), hsize, expectedRemaining)
324+
}
325+
}
326+
}
327+
// Return the body without verification - we'll verify the complete file later
328+
return io.NopCloser(resp.Body), nil
329+
}
330+
331+
// For full downloads, verify the stream
332+
// Do whatever we can with size validation
268333
if hsize := resp.ContentLength; hsize != -1 {
269334
if size == verify.SizeUnknown {
270335
size = hsize
271336
} else if hsize != size {
337+
resp.Body.Close()
272338
return nil, fmt.Errorf("GET %s: Content-Length header %d does not match expected size %d", u.String(), hsize, size)
273339
}
274340
}

pkg/v1/remote/image.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package remote
1717
import (
1818
"bytes"
1919
"context"
20+
"fmt"
2021
"io"
2122
"net/http"
2223
"net/url"
@@ -195,6 +196,9 @@ func (rl *remoteImageLayer) Compressed() (io.ReadCloser, error) {
195196
urls = append(urls, *u)
196197
}
197198

199+
// Check if we should resume from a specific offset
200+
resumeOffset := getResumeOffset(ctx, rl.digest.String())
201+
198202
// The lastErr for most pulls will be the same (the first error), but for
199203
// foreign layers we'll want to surface the last one, since we try to pull
200204
// from the registry first, which would often fail.
@@ -206,18 +210,46 @@ func (rl *remoteImageLayer) Compressed() (io.ReadCloser, error) {
206210
return nil, err
207211
}
208212

213+
// Add Range header for resumable downloads
214+
if resumeOffset > 0 {
215+
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
216+
}
217+
209218
resp, err := rl.ri.fetcher.Do(req.WithContext(ctx))
210219
if err != nil {
211220
lastErr = err
212221
continue
213222
}
214223

215-
if err := transport.CheckError(resp, http.StatusOK); err != nil {
224+
// Accept both 200 OK (full content) and 206 Partial Content (resumed)
225+
if err := transport.CheckError(resp, http.StatusOK, http.StatusPartialContent); err != nil {
216226
resp.Body.Close()
217227
lastErr = err
218228
continue
219229
}
220230

231+
// If we requested a range but got 200, server doesn't support ranges
232+
// We'll get the full content
233+
if resumeOffset > 0 && resp.StatusCode == http.StatusOK {
234+
resumeOffset = 0
235+
}
236+
237+
// For partial content (resumed downloads), we can't verify the hash on the stream
238+
// since we're only getting part of the file. The complete file will be verified
239+
// after all bytes are written to disk.
240+
if resumeOffset > 0 && resp.StatusCode == http.StatusPartialContent {
241+
// Verify we got the expected remaining size
242+
expectedRemaining := d.Size - resumeOffset
243+
if resp.ContentLength != -1 && resp.ContentLength != expectedRemaining {
244+
resp.Body.Close()
245+
lastErr = fmt.Errorf("partial content size mismatch: got %d, expected %d", resp.ContentLength, expectedRemaining)
246+
continue
247+
}
248+
// Return the body without verification - we'll verify the complete file later
249+
return io.NopCloser(resp.Body), nil
250+
}
251+
252+
// For full downloads, verify the stream
221253
return verify.ReadCloser(resp.Body, d.Size, rl.digest)
222254
}
223255

0 commit comments

Comments
 (0)