diff --git a/pkg/registry/blobs.go b/pkg/registry/blobs.go index c83e54799..c4af9d5b2 100644 --- a/pkg/registry/blobs.go +++ b/pkg/registry/blobs.go @@ -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 ®Error{ - 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 ®Error{ + 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 diff --git a/pkg/v1/remote/fetcher.go b/pkg/v1/remote/fetcher.go index d77b37c0c..1ce29815f 100644 --- a/pkg/v1/remote/fetcher.go +++ b/pkg/v1/remote/fetcher.go @@ -245,6 +245,37 @@ 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) @@ -252,23 +283,58 @@ func (f *fetcher) fetchBlob(ctx context.Context, size int64, h v1.Hash) (io.Read 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) } } diff --git a/pkg/v1/remote/image.go b/pkg/v1/remote/image.go index f085967ed..896b2ef07 100644 --- a/pkg/v1/remote/image.go +++ b/pkg/v1/remote/image.go @@ -17,6 +17,7 @@ package remote import ( "bytes" "context" + "fmt" "io" "net/http" "net/url" @@ -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. @@ -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) }