Design¶
go-oci-blob is a Go library that uploads and downloads OCI blobs. That is the whole library. This page records the design and the reasoning behind it.
Scope¶
In scope:
- Upload (push) a blob to an OCI registry.
- Download (pull) a blob from an OCI registry.
- Check that a blob exists.
- Cross-repository blob mount, with fallback to a normal push.
- Retries, resume, and digest verification for all of the above.
Out of scope:
- Manifests, tags, referrers, and the rest of the OCI distribution spec.
- Authentication and credential storage. The caller injects an authenticated
registry
http.RoundTripper; libraries such asoras-goandgo-containerregistryalready provide one. - Storage destination policy. The caller can inject a separate guarded transport for off-origin storage and CDN requests.
- Signatures, attestations, and image tooling of any kind.
Every line of code must serve blob transfer. When a feature request falls outside that sentence, the answer is no.
Dependencies¶
The runtime dependency list is the Go standard library plus
github.com/opencontainers/go-digest.
go-digest is included for interoperability, not function. The libraries that
callers pair with this one already use digest.Digest, so our API accepts it
directly. The alternative was a local ~50 line digest type; it would work but
would force every caller to convert strings at the boundary.
Test-only dependencies (mockery, testify, testcontainers) do not ship to consumers.
Architecture¶
The library follows hexagonal architecture: business logic runs and tests without side effects, and all I/O sits behind ports. The split:
- Core (pure logic, no I/O): request planning, response interpretation, retry decisions, chunking, and digest bookkeeping. This code runs and tests without a network.
- Port: a single interface over one HTTP round trip.
http.RoundTripperalready has the right shape, so the port is the caller-injected transport. This one seam covers both testing and authentication. - Adapter:
net/httpwith a small wrapper that executes a planned request and hands the response back to the core.
The public surface is one package, blob. Internal helpers move to internal/
packages when a file nears the repository's 1,000-line cap, not before.
Public API¶
The authoritative API contract is the package documentation. The shape, for orientation:
// Repository addresses a blob store: a registry host plus a repository name.
type Repository struct {
Host string // "registry.example.com" or "localhost:5000"
Name string // "library/ubuntu"
}
func New(opts ...Option) *Client
// Client options: WithTransport(http.RoundTripper), WithStorageTransport(http.RoundTripper),
// WithRetryPolicy(...), WithWriteRedirects(bool), WithPlainHTTP(bool),
// WithChunkedUpload(chunkSize int64), WithParallelPull(workers int, chunkSize int64)
// Per-call options: WithProgress(fn func(done, total int64)),
// WithWireProgress(fn func(delta int64))
func (c *Client) Exists(ctx context.Context, repo Repository, dgst digest.Digest) (bool, error)
func (c *Client) Pull(ctx context.Context, repo Repository, dgst digest.Digest, opts ...TransferOption) (io.ReadCloser, error)
func (c *Client) PullRange(ctx context.Context, repo Repository, dgst digest.Digest, offset, length int64, opts ...TransferOption) (io.ReadCloser, error)
func (c *Client) Push(ctx context.Context, repo Repository, dgst digest.Digest, size int64, r io.Reader, opts ...TransferOption) error
func (c *Client) Mount(ctx context.Context, dst, src Repository, dgst digest.Digest) (bool, error)
API decisions:
Pullreturns anio.ReadCloserinstead of writing to anio.Writer. A reader composes with more caller code and never buffers the blob. The reader verifies the digest as bytes flow; the finalReadreturnsErrDigestMismatchinstead ofio.EOFwhen the hash does not match.Pushrequires the digest and size up front. Registries need the digest to commit an upload, and the size setsContent-Length. Size is mandatory: there is no unknown-length upload, and the client rejects both short and trailing input. The reader must reach EOF immediately after that size; a streaming producer closes its pipe after the final byte. A caller that does not know the size spools the data first and comes back with a number.PullRangeserves partial blobs through a rangedGET. It never verifies the digest: the digest covers the whole blob, so a partial body cannot be checked against it. The client therefore validates everyContent-Rangebefore exposing bytes and follows shorter valid portions through at most 16 successful206responses. Further fragmentation returns an error instead of issuing more requests.Pullis the verified path; callers that need integrity on partial reads build it above the library.WithProgress(fn)reports cumulative committed transfer progress. The callback receives bytes moved and the total (-1when unknown), runs synchronously on the transfer path, and must return quickly. Pull counts bytes delivered to the caller. Monolithic Push reports after the final201; chunked Push advances after each PATCH acknowledgement, so only a nil Push error proves the final commit succeeded.WithWireProgress(fn)reports positive upload-byte deltas when the HTTP transport consumes a request body. Source read-ahead does not count. Failed attempts, redirects, and transparent retries count because they consumed boundary traffic. Calls stop before Push returns.- Calls to either progress callback do not overlap within one transfer. Concurrent transfers may call the same function concurrently, so callers must protect state shared across transfers.
Mountreturns(false, nil)when the registry declines the mount. The caller then decides whether to push. Mount-with-automatic-push-fallback can be layered on later if real use shows the need.- Defaults are the code paths every registry serves correctly: monolithic upload and single-stream download. Chunked upload and parallel pull exist behind toggles and are never chosen automatically.
Wire behavior¶
The blob subset of the distribution spec is five request shapes:
| Operation | Request |
|---|---|
| Existence | HEAD /v2/<name>/blobs/<digest> |
| Pull | GET /v2/<name>/blobs/<digest> |
| Start upload / mount | POST /v2/<name>/blobs/uploads/ |
| Chunked upload | PATCH <location> with Content-Range |
| Commit upload | PUT <location>?digest=<digest> |
Rules the client follows:
- Validate endpoint-specific success statuses.
200 OKproves existence or a full pull,206 Partial Contentcarries a validated range,202 Acceptedopens or advances an upload session, and201 Createdcompletes a commit or mount. An unexpected 2xx response is not promoted to a terminal success. - Resolve
Locationheaders as relative or absolute HTTP(S) URLs. Reject missing, malformed, hostless, credential-bearing, unsupported-scheme, and HTTPS-to-HTTP locations. Preserve opaque query bytes when appending or replacing the digest parameter required for commit. Errors do not render the peer-selected value, resolved URL, signed query, path, userinfo, or fragment. - Scope the caller's potentially authenticated registry transport to the
registry origin. Absolute upload locations and cross-origin redirects use a
separate storage transport with
Authorization,Proxy-Authorization, cookies, andRefererremoved. This routing happens before either transport runs, so an auth wrapper cannot add registry credentials to a storage request. The caller's storage transport owns private-network and actual-peer destination policy. - Follow read redirects normally. By default, a write redirect must preserve
the method and have a replayable body (
307or308); redirects that would turn a write into a bodylessGETare rejected.WithWriteRedirects(false)rejects every redirect that would reissuePOST,PUT,PATCH, orDELETEbefore the target request is sent. Successful upload-sessionLocationresponses remain accepted because they are protocol state, not HTTP redirects. Redirect targets must remain HTTP(S), redirect loops stop at ten hops, and redirect-policy failures are terminal. - Retain parsed OCI error status for programmatic inspection, but omit registry response-body detail from ordinary rendered errors. A registry can reflect credentials or terminal controls in that body.
- Upload monolithically (single
PUT) unless the caller setsWithChunkedUpload. Chunked upload is spec-optional and broken on major hosted registries (ECR discards chunks after the first and still returns success; Docker Hub and GHCR have similar reports), because mainstream clients never exercise it. It is an explicit opt-in, not a fallback. - In chunked mode, honor
OCI-Chunk-Min-Lengthand verify theRangeheader after everyPATCH. If the acknowledged range does not advance by the chunk just sent, abandon the session and fail the upload with a descriptive error. The digest-verified commitPUTis the backstop: a registry that dropped bytes fails the commit rather than storing a bad blob. A non-SHA-256 upload names its digest algorithm when opening the session.
Reliability¶
Retry policy:
Newapplies the default retry policy: four total attempts, full-jitter backoff seeded at 250 milliseconds, and a 30-second maximum delay.WithRetryPolicy(RetryPolicy{})selects exactly one attempt so an embedding orchestrator can own the outer retry budget.- Retry connection errors, request timeouts, registry
429, and registry5xx. Off-origin storage401,403,404, and410also warrant a fresh upload attempt because the next registry session can select a new location. Other4xxresponses are terminal. - Preserve retry classification and the usable
Retry-Afterfloor on returned errors.Retryable(err)exposes both after wrapping or policy exhaustion;StatusCode(err)exposes a retained HTTP status. MaxAttemptsbounds the complete request, including parallel chunk body retries; nested retry loops do not multiply it.- The caller's
contextstops network work, retries, and backoff immediately and remains inspectable througherrors.Ison the returned error. An arbitraryio.Readerhas no cancellation operation, soPushstill waits for an in-flight sourceReadto return before it gives the reader back to the caller. A blocking producer must arrange to unblock its reader when the context ends. - Once an upload session will not be continued, send a best-effort
DELETEwhile the caller's context remains active, with a five-second upper bound. Cleanup failure never replaces the original transfer error.
Downloads resume; uploads restart:
- Download: on a broken stream, issue a ranged
GETfrom the last verified byte, gated on the registry serving ranges (Accept-Rangesor a206response). A resume-time416withContent-Range: bytes */Nis terminal only whenNexactly matches the delivered offset and any known total. The digest state carries across every resume, and full digest verification still decides whether the download succeeds. - Upload: a failed upload restarts from byte zero. The spec defines session
resume (
GETon the upload URL returns the receivedRange), but the registries that break chunked upload break resume with it, and no mainstream client exercises the path. It stays out until a consumer demonstrates the need. Restarting requires the caller-supplied reader to be re-readable or seekable; when it is not, the upload fails after the first attempt.
Errors¶
Sentinel errors remain high-level:
ErrNotFound: the registry-origin blob or repository does not exist.ErrUnauthorized: the registry origin returned401or403.ErrTooLarge: the registry origin returned413.ErrDigestMismatch: bytes did not hash to the expected digest.
Off-origin storage failures do not match registry authorization or not-found
sentinels. StatusCode retains response status without requiring text parsing.
Retryable retains retry classification and Retry-After. Ordinary error
strings provide structural operation context but omit registry response-body
details and peer-selected upload locations.
Testing¶
Three layers:
- Unit tests on the pure core: response interpretation, retry decisions, range math, digest bookkeeping.
- Integration tests with a mockery-generated transport mock, driving the client against scripted registry conversations, including the misbehaving ones.
- End-to-end tests with testcontainers against real registries. Start with
registry:2andzot; both are OCI-conformant and run cheaply in CI.
The tolerance rules in "Wire behavior" are the heart of the library, so the scripted-conversation suite is the largest of the three.
Performance¶
- Stream everything. No code path holds a whole blob in memory.
- Upload request bodies stage up to four 256 KiB batches. This removes a
scheduler handoff for every small transport read while preserving prompt
Closeand caller-reader ownership. Small bodies allocate proportionally; active staging is capped at 1 MiB per request, and the process retains at most 2 MiB of idle upload buffers. - Parallel chunk buffers use a bounded client cache rather than
sync.Pool. The cache retains at most one buffer per configured worker and drops excess buffers returned by concurrent pulls. - Connection reuse and HTTP/2 come from the transport. For parallel pulls, the client sizes its library-owned default HTTP/1 idle pool to the worker count; caller-supplied transports remain caller-tuned. The client never defeats reuse by closing bodies early or rebuilding clients.
Parallel pull is the library's one extra feature. It is off by default;
WithParallelPull(workers, chunkSize) turns it on.
Pullkeeps the same signature and still returns a verifyingio.ReadCloser. Workers fetch rangedGETs concurrently; the reader emits chunks in order, so digest verification works unchanged.- A fixed worker set handles every chunk. Goroutine and task-channel counts therefore follow the configured worker count rather than the blob's chunk count.
- Payload buffering is bounded by roughly
workers × chunkSize, and active response bodies are bounded byworkers; the initial range probe consumes one of those worker slots. This is the one deliberate exception to "never buffer", and the caller sets that bound. - Validate each ranged response's start, end, and total. A registry may return a shorter valid portion, which the same worker completes without changing output order. One scheduled chunk accepts at most 16 successful portions, so a pathological server cannot turn one range into an unbounded request loop.
- Closing the reader cancels the probe, queued work, active bodies, and retry backoff before returning. A progress callback may request that close without waiting on its own worker result.
- If the registry does not serve ranges,
Pullfalls back to a single stream. The toggle states intent, not a requirement. - A scatter-write variant (
io.WriterAtsink) was rejected: it would add a second pull API and break streaming verification for little gain.