Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions pkg/registry/blobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,13 +302,19 @@ func (b *blobs) handle(resp http.ResponseWriter, req *http.Request) *regError {
}

if rangeHeader != "" {
start, end := int64(0), int64(0)
start, end := int64(0), size-1
// Try parsing as "bytes=start-end" first
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
return &regError{
Status: http.StatusRequestedRangeNotSatisfiable,
Code: "BLOB_UNKNOWN",
Message: "We don't understand your Range",
// Try parsing as "bytes=start-" (open-ended range)
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-", &start); err != nil {
return &regError{
Status: http.StatusRequestedRangeNotSatisfiable,
Code: "BLOB_UNKNOWN",
Message: "We don't understand your Range",
}
}
// For open-ended range, end is the last byte of the blob
end = size - 1
}

n := (end + 1) - start
Expand Down
74 changes: 70 additions & 4 deletions pkg/v1/remote/fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,30 +245,96 @@ func (f *fetcher) headManifest(ctx context.Context, ref name.Reference, acceptab
}, nil
}

// contextKey is a type for context keys used in this package
type contextKey string

const resumeOffsetKey contextKey = "resumeOffset"
const resumeOffsetsKey contextKey = "resumeOffsets"

// WithResumeOffset returns a context with the resume offset set for a single blob
func WithResumeOffset(ctx context.Context, offset int64) context.Context {
return context.WithValue(ctx, resumeOffsetKey, offset)
}

// WithResumeOffsets returns a context with resume offsets for multiple blobs (keyed by digest)
func WithResumeOffsets(ctx context.Context, offsets map[string]int64) context.Context {
return context.WithValue(ctx, resumeOffsetsKey, offsets)
}

// getResumeOffset retrieves the resume offset from context for a given digest
func getResumeOffset(ctx context.Context, digest string) int64 {
// First check if there's a specific offset for this digest
if offsets, ok := ctx.Value(resumeOffsetsKey).(map[string]int64); ok {
if offset, found := offsets[digest]; found && offset > 0 {
return offset
}
}
// Fall back to single offset (for fetchBlob)
if offset, ok := ctx.Value(resumeOffsetKey).(int64); ok {
return offset
}
return 0
}

func (f *fetcher) fetchBlob(ctx context.Context, size int64, h v1.Hash) (io.ReadCloser, error) {
u := f.url("blobs", h.String())
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}

// Check if we should resume from a specific offset
resumeOffset := getResumeOffset(ctx, h.String())
if resumeOffset > 0 {
// Add Range header to resume download
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
}

resp, err := f.client.Do(req.WithContext(ctx))
if err != nil {
return nil, redact.Error(err)
}

if err := transport.CheckError(resp, http.StatusOK); err != nil {
// Accept both 200 OK (full content) and 206 Partial Content (resumed)
if resumeOffset > 0 {
// If we requested a Range but got 200, the server doesn't support ranges
// We'll have to download from scratch
if resp.StatusCode == http.StatusOK {
// Server doesn't support range requests, will download full content
resumeOffset = 0
}
}

if err := transport.CheckError(resp, http.StatusOK, http.StatusPartialContent); err != nil {
resp.Body.Close()
return nil, err
}

// Do whatever we can.
// If we have an expected size and Content-Length doesn't match, return an error.
// If we don't have an expected size and we do have a Content-Length, use Content-Length.
// For partial content (resumed downloads), we can't verify the hash on the stream
// since we're only getting part of the file. The complete file will be verified
// after all bytes are written to disk.
if resumeOffset > 0 && resp.StatusCode == http.StatusPartialContent {
// Verify Content-Length matches expected remaining size
if hsize := resp.ContentLength; hsize != -1 {
if size != verify.SizeUnknown {
expectedRemaining := size - resumeOffset
if hsize != expectedRemaining {
resp.Body.Close()
return nil, fmt.Errorf("GET %s: Content-Length header %d does not match expected remaining size %d", u.String(), hsize, expectedRemaining)
}
}
}
// Return the body without verification - we'll verify the complete file later
return io.NopCloser(resp.Body), nil
}

// For full downloads, verify the stream
// Do whatever we can with size validation
if hsize := resp.ContentLength; hsize != -1 {
if size == verify.SizeUnknown {
size = hsize
} else if hsize != size {
resp.Body.Close()
return nil, fmt.Errorf("GET %s: Content-Length header %d does not match expected size %d", u.String(), hsize, size)
}
}
Expand Down
34 changes: 33 additions & 1 deletion pkg/v1/remote/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package remote
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
Expand Down Expand Up @@ -195,6 +196,9 @@ func (rl *remoteImageLayer) Compressed() (io.ReadCloser, error) {
urls = append(urls, *u)
}

// Check if we should resume from a specific offset
resumeOffset := getResumeOffset(ctx, rl.digest.String())

// The lastErr for most pulls will be the same (the first error), but for
// foreign layers we'll want to surface the last one, since we try to pull
// from the registry first, which would often fail.
Expand All @@ -206,18 +210,46 @@ func (rl *remoteImageLayer) Compressed() (io.ReadCloser, error) {
return nil, err
}

// Add Range header for resumable downloads
if resumeOffset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
}

resp, err := rl.ri.fetcher.Do(req.WithContext(ctx))
if err != nil {
lastErr = err
continue
}

if err := transport.CheckError(resp, http.StatusOK); err != nil {
// Accept both 200 OK (full content) and 206 Partial Content (resumed)
if err := transport.CheckError(resp, http.StatusOK, http.StatusPartialContent); err != nil {
resp.Body.Close()
lastErr = err
continue
}

// If we requested a range but got 200, server doesn't support ranges
// We'll get the full content
if resumeOffset > 0 && resp.StatusCode == http.StatusOK {
resumeOffset = 0
}

// For partial content (resumed downloads), we can't verify the hash on the stream
// since we're only getting part of the file. The complete file will be verified
// after all bytes are written to disk.
if resumeOffset > 0 && resp.StatusCode == http.StatusPartialContent {
// Verify we got the expected remaining size
expectedRemaining := d.Size - resumeOffset
if resp.ContentLength != -1 && resp.ContentLength != expectedRemaining {
resp.Body.Close()
lastErr = fmt.Errorf("partial content size mismatch: got %d, expected %d", resp.ContentLength, expectedRemaining)
continue
}
// Return the body without verification - we'll verify the complete file later
return io.NopCloser(resp.Body), nil
}

// For full downloads, verify the stream
return verify.ReadCloser(resp.Body, d.Size, rl.digest)
}

Expand Down