Skip to content

Commit 345fc6a

Browse files
committed
Add resumable download support via range requests
Add documentation and comprehensive tests for resumable downloads Add resumable download example and update gitignore Signed-off-by: Eric Curtin <eric.curtin@docker.com>
1 parent cb4a037 commit 345fc6a

File tree

8 files changed

+369
-9
lines changed

8 files changed

+369
-9
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ cmd/gcrane/gcrane
1010
cmd/krane/krane
1111

1212
.DS_Store
13+
coverage.txt
14+
examples/*/main
15+
examples/resumable-download/resumable-download
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Resumable Download Example
2+
3+
This example demonstrates how to use the resumable download feature to fetch specific byte ranges from container registry layers using HTTP range requests.
4+
5+
## Usage
6+
7+
```bash
8+
go run main.go <digest-ref> <start-byte> <end-byte>
9+
```
10+
11+
## Example
12+
13+
```bash
14+
# Fetch the first 1024 bytes from a layer
15+
go run main.go gcr.io/my-repo/my-image@sha256:abc123... 0 1023
16+
17+
# Resume a download starting from byte 1024
18+
go run main.go gcr.io/my-repo/my-image@sha256:abc123... 1024 2047
19+
```
20+
21+
## Use Cases
22+
23+
- **Resumable Downloads**: If a download is interrupted, you can resume from where it left off
24+
- **Partial Content Access**: Access only the portion of a layer you need
25+
- **Progressive Loading**: Load content incrementally for better user experience
26+
- **Bandwidth Optimization**: Download only the required portions of large layers
27+
28+
## Notes
29+
30+
- Range requests require a digest reference (not a tag)
31+
- The byte offsets are inclusive (start and end bytes are both included)
32+
- Hash verification is not performed on partial content
33+
- Not all registries support range requests (though most modern ones do)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright 2024 Google LLC All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// This example demonstrates how to use resumable downloads to fetch
16+
// specific byte ranges from container registry layers.
17+
package main
18+
19+
import (
20+
"fmt"
21+
"io"
22+
"log"
23+
"os"
24+
25+
"github.com/google/go-containerregistry/pkg/name"
26+
"github.com/google/go-containerregistry/pkg/v1/remote"
27+
)
28+
29+
func main() {
30+
if len(os.Args) < 4 {
31+
fmt.Fprintf(os.Stderr, "Usage: %s <digest-ref> <start-byte> <end-byte>\n", os.Args[0])
32+
fmt.Fprintf(os.Stderr, "Example: %s gcr.io/my-repo/my-image@sha256:abc123... 0 1023\n", os.Args[0])
33+
os.Exit(1)
34+
}
35+
36+
// Parse the digest reference
37+
ref, err := name.NewDigest(os.Args[1])
38+
if err != nil {
39+
log.Fatalf("Failed to parse digest: %v", err)
40+
}
41+
42+
// Parse start and end byte offsets
43+
var start, end int64
44+
if _, err := fmt.Sscanf(os.Args[2], "%d", &start); err != nil {
45+
log.Fatalf("Failed to parse start byte: %v", err)
46+
}
47+
if _, err := fmt.Sscanf(os.Args[3], "%d", &end); err != nil {
48+
log.Fatalf("Failed to parse end byte: %v", err)
49+
}
50+
51+
// Fetch the byte range
52+
log.Printf("Fetching bytes %d-%d from %s...", start, end, ref.Name())
53+
rc, err := remote.LayerRange(ref, start, end)
54+
if err != nil {
55+
log.Fatalf("Failed to fetch byte range: %v", err)
56+
}
57+
defer rc.Close()
58+
59+
// Copy the range to stdout
60+
n, err := io.Copy(os.Stdout, rc)
61+
if err != nil {
62+
log.Fatalf("Failed to read bytes: %v", err)
63+
}
64+
65+
log.Printf("\nSuccessfully read %d bytes", n)
66+
}

pkg/v1/remote/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,46 @@ func main() {
3434
}
3535
```
3636

37+
### Resumable Downloads
38+
39+
The `remote` package supports resumable downloads via HTTP range requests. This is useful for downloading large layers or for resuming interrupted downloads:
40+
41+
```go
42+
package main
43+
44+
import (
45+
"io"
46+
"os"
47+
48+
"github.com/google/go-containerregistry/pkg/name"
49+
"github.com/google/go-containerregistry/pkg/v1/remote"
50+
)
51+
52+
func main() {
53+
// Parse a blob reference (digest)
54+
ref, err := name.NewDigest("gcr.io/my-repo/my-image@sha256:abcd...")
55+
if err != nil {
56+
panic(err)
57+
}
58+
59+
// Download a specific byte range (bytes 1000-9999)
60+
// This is useful for resuming downloads or fetching specific portions
61+
rc, err := remote.LayerRange(ref, 1000, 9999)
62+
if err != nil {
63+
panic(err)
64+
}
65+
defer rc.Close()
66+
67+
// Copy the range to a file or process it
68+
_, err = io.Copy(os.Stdout, rc)
69+
if err != nil {
70+
panic(err)
71+
}
72+
}
73+
```
74+
75+
Note: When using range requests, hash verification is not performed on the partial content. To verify the integrity of the full blob, download it completely using `remote.Layer()` instead.
76+
3777
## Structure
3878

3979
<p align="center">

pkg/v1/remote/doc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,8 @@
1414

1515
// Package remote provides facilities for reading/writing v1.Images from/to
1616
// a remote image registry.
17+
//
18+
// This package supports resumable downloads via HTTP range requests. Use
19+
// LayerRange to download specific byte ranges of layer blobs, which is useful
20+
// for resuming interrupted downloads or implementing progressive loading.
1721
package remote

pkg/v1/remote/fetcher.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,18 +246,41 @@ func (f *fetcher) headManifest(ctx context.Context, ref name.Reference, acceptab
246246
}
247247

248248
func (f *fetcher) fetchBlob(ctx context.Context, size int64, h v1.Hash) (io.ReadCloser, error) {
249+
return f.fetchBlobRange(ctx, size, h, nil)
250+
}
251+
252+
// ByteRange represents a byte range for partial blob downloads.
253+
type ByteRange struct {
254+
Start int64 // Starting byte offset (inclusive)
255+
End int64 // Ending byte offset (inclusive)
256+
}
257+
258+
// fetchBlobRange fetches a blob or a byte range of a blob.
259+
// If byteRange is nil, fetches the entire blob.
260+
func (f *fetcher) fetchBlobRange(ctx context.Context, size int64, h v1.Hash, byteRange *ByteRange) (io.ReadCloser, error) {
249261
u := f.url("blobs", h.String())
250262
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
251263
if err != nil {
252264
return nil, err
253265
}
254266

267+
// Add Range header if byte range is specified
268+
if byteRange != nil {
269+
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byteRange.Start, byteRange.End))
270+
}
271+
255272
resp, err := f.client.Do(req.WithContext(ctx))
256273
if err != nil {
257274
return nil, redact.Error(err)
258275
}
259276

260-
if err := transport.CheckError(resp, http.StatusOK); err != nil {
277+
// For range requests, we expect either 200 (full content) or 206 (partial content)
278+
expectedStatus := http.StatusOK
279+
if byteRange != nil {
280+
expectedStatus = http.StatusPartialContent
281+
}
282+
283+
if err := transport.CheckError(resp, http.StatusOK, expectedStatus); err != nil {
261284
resp.Body.Close()
262285
return nil, err
263286
}
@@ -266,13 +289,27 @@ func (f *fetcher) fetchBlob(ctx context.Context, size int64, h v1.Hash) (io.Read
266289
// If we have an expected size and Content-Length doesn't match, return an error.
267290
// If we don't have an expected size and we do have a Content-Length, use Content-Length.
268291
if hsize := resp.ContentLength; hsize != -1 {
269-
if size == verify.SizeUnknown {
270-
size = hsize
271-
} else if hsize != size {
272-
return nil, fmt.Errorf("GET %s: Content-Length header %d does not match expected size %d", u.String(), hsize, size)
292+
// For range requests, Content-Length is the size of the range, not the full blob
293+
if byteRange != nil {
294+
expectedRangeSize := byteRange.End - byteRange.Start + 1
295+
if hsize != expectedRangeSize {
296+
return nil, fmt.Errorf("GET %s: Content-Length header %d does not match expected range size %d", u.String(), hsize, expectedRangeSize)
297+
}
298+
} else {
299+
if size == verify.SizeUnknown {
300+
size = hsize
301+
} else if hsize != size {
302+
return nil, fmt.Errorf("GET %s: Content-Length header %d does not match expected size %d", u.String(), hsize, size)
303+
}
273304
}
274305
}
275306

307+
// For range requests, we cannot verify the hash of partial content
308+
if byteRange != nil {
309+
// Just return the response body without hash verification
310+
return resp.Body, nil
311+
}
312+
276313
return verify.ReadCloser(resp.Body, size, h)
277314
}
278315

pkg/v1/remote/layer.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,17 @@ import (
2727

2828
// remoteImagelayer implements partial.CompressedLayer
2929
type remoteLayer struct {
30-
ctx context.Context
31-
fetcher fetcher
32-
digest v1.Hash
30+
ctx context.Context
31+
fetcher fetcher
32+
digest v1.Hash
33+
byteRange *ByteRange
3334
}
3435

3536
// Compressed implements partial.CompressedLayer
3637
func (rl *remoteLayer) Compressed() (io.ReadCloser, error) {
3738
// We don't want to log binary layers -- this can break terminals.
3839
ctx := redact.NewContext(rl.ctx, "omitting binary blobs from logs")
39-
return rl.fetcher.fetchBlob(ctx, verify.SizeUnknown, rl.digest)
40+
return rl.fetcher.fetchBlobRange(ctx, verify.SizeUnknown, rl.digest, rl.byteRange)
4041
}
4142

4243
// Compressed implements partial.CompressedLayer
@@ -75,3 +76,30 @@ func Layer(ref name.Digest, options ...Option) (v1.Layer, error) {
7576
}
7677
return newPuller(o).Layer(o.context, ref)
7778
}
79+
80+
// LayerRange reads a byte range of the given blob reference from a registry as an io.ReadCloser.
81+
// A blob reference here is just a punned name.Digest where the digest portion is the
82+
// digest of the blob to be read and the repository portion is the repo where that blob lives.
83+
//
84+
// The byte range is specified with start and end offsets (both inclusive).
85+
// This is useful for resumable downloads where you want to download a specific portion of a layer.
86+
//
87+
// Note: Since this returns partial content, hash verification is not performed on the returned data.
88+
func LayerRange(ref name.Digest, start, end int64, options ...Option) (io.ReadCloser, error) {
89+
o, err := makeOptions(options...)
90+
if err != nil {
91+
return nil, err
92+
}
93+
94+
f, err := makeFetcher(o.context, ref.Context(), o)
95+
if err != nil {
96+
return nil, err
97+
}
98+
99+
h, err := v1.NewHash(ref.Identifier())
100+
if err != nil {
101+
return nil, err
102+
}
103+
104+
return f.fetchBlobRange(o.context, verify.SizeUnknown, h, &ByteRange{Start: start, End: end})
105+
}

0 commit comments

Comments
 (0)