The cost of failed downloads being restarted by deleting partially downloaded layers is a high one to pay in the Salad network especially when some of these layers may exceed 10GB. Resuming partial downloads is an important part of a robust and resilient and performant distributed compute node with limited bandwidth.
This repository is a fork of https://github.com/oras-project/oras-go/ just after the v2.5.0
tag. The oras-main
contains
the upstream main
branch while
main
contains
Salad's download changes. The only changes required to build the ORAS CLI (oras
)
(https://github.com/oras-project/oras) are to use this replacement
for oras-go
.
Typically the only change required is a replace
line in go.mod
, setting the
psuedo-version to the desired commit:
replace oras.land/oras-go/v2 v2.5.0 => github.com/saladtechnologies/oras-go/v2 v2.0.0-20240409062726-11d464f8432e
This resumable download implementation is contained entirely within oras-go
and the code path
below oras.doCopyNode()
. Attempts have been made to not alter the existing external interfaces
although some new ones have been added. Resume download is always enabled but conditions are
carefully evaluated and falls back to the original code path when not possible. This
implementation does not include any way to force resume enabled (fail if not possible) or
disabled (do not attempt even when possible).
Resumable downloads are limited to remote registry source targets and local storage destination targets. This is the use case for prepping containerd images.
-
Annotations
key constants (internal/spec/artifact.go
)- AnnotationResume* - the keys used in the Annotations[] map
- The Annotations field of the Descriptor is used to pass state around during the request handling. This avoids changing the public API via interfaces or structs.
- Salad-specific keys are defined in
internal/spec/artifact.go
using constants with names beginning withAnnotationResume
.
- AnnotationResume* - the keys used in the Annotations[] map
-
oras.doCopyNode()
(copy.go
)- Look for files in the ingest directory that match the current
Descriptor
being downloaded- if found: save full filename and file size to the
Annotations
map for theDescriptor
- if not found: nothing to see here, proceed as normal
- if found: save full filename and file size to the
- Look for files in the ingest directory that match the current
-
remote.FetcherHead
(registry/remote/repository.go
)- interface defining
FetchHead()
- interface defining
-
remote.BlobStoreHead
(registry/remote/repository.go
)- interface combining
registry.BlobStore
withFetcherHead
- interface combining
-
remote.Repository.FetchHead()
(new) (registry/remote/repository.go
)- call
FetchHead()
whenBlobStoreHead
is implemented
- call
-
remote.blobStore
(registry/remote/repository.go
)blobStore.Fetch()
- call
FetchHead()
to check for theRange
header support from the server- FALSE:
- reset resume flag and proceed as usual
- TRUE:
- Set
Range
header
- Set
- FALSE:
- after GET request to remote repository if in resume
StatusPartialContent
:- check response
ContentLength
againsttarget.Size - ingestSize
- check response
StatusOK
:- check response
ContentLength
againsttarget.Size
- check response
- call
blobStore.FetchHead()
(new)- do HEAD call to src
StatusOK
:- check response
ContentLength
againsttarget.Size
- check response header
Accept-Ranges
has valuebytes
- TRUE:
- Set resume flag
- TRUE:
- check response
- do HEAD call to src
-
content.Storage.Push()
(content/oci/storage.go
)- call
Storage.ingest()
as usual
- call
-
content.Storage.ingest()
(content/oci/storage.go
)- if resume conditions are all met
- TRUE:
- open existing ingest file
- seek to 0 in ingest file
- create a new Hash to contain the current hash of the ingest file
- save encoded Hash to
Annotations[hash]
- FALSE:
- if not found:
CreateTemp()
a new ingest file as usual
- if not found:
- TRUE:
- if
0 <= ingest size < content-length
- TRUE:
- call
ioutil.CopyBuffer()
as usual
- call
- TRUE:
- if resume conditions are all met
-
ioutil.CopyBuffer()
(internal/ioutil.io.go
)- call
content.NewVerifyReader()
as usual - handle
io.ErrUnexpectedEOF
: checkbytes read == desc.Size - ingestSize
- call
-
content.NewVerifyReader()
(content/reader.go
)- Add
resume
field toVerifyReader
struct - if
Annotations[offset]
> 0- TRUE:
- decode
Annotations[Hash]
- create a new
content.hashVerifier
with the newHash
and the originaldesc.Digest
- decode
- FALSE:
- create a new
digest.hashVerifier
fromdesc.Digest
- create a new
- TRUE:
- Add
-
content.hashVerifier
(new) (content/verifiers.go
)digest.hashVerifier
is copied here fromopencontainers/go-digest/blob/master/verifiers.go
because it is private and we need to construct a verifier with our newHash
and the originalDigest
.
-
content.Resumer
(new) (content.storage.go
)- Interface to get ingest filenames, also used to determine support for resumable downloads
-
content.Store.IngestFile()
(new) (content/oci/storage.go
)- Provide access to
content.Store.storage.IngestFile()
- Provide access to
-
content.Storage.IngestFile()
(new) (content/oci/storage.go
)- Locate and return the first matching ingest file, if any