cuelabs.dev/go/oci/ociregistry@v0.0.0-20240906074133-82eb438dd565/ociclient/writer.go (about)

     1  // Copyright 2023 CUE Labs AG
     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  package ociclient
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"fmt"
    21  	"io"
    22  	"net/http"
    23  	"net/url"
    24  	"strconv"
    25  	"strings"
    26  	"sync"
    27  
    28  	"github.com/opencontainers/go-digest"
    29  
    30  	"cuelabs.dev/go/oci/ociregistry"
    31  	"cuelabs.dev/go/oci/ociregistry/internal/ocirequest"
    32  	"cuelabs.dev/go/oci/ociregistry/ociauth"
    33  )
    34  
    35  // This file implements the ociregistry.Writer methods.
    36  
    37  func (c *client) PushManifest(ctx context.Context, repo string, tag string, contents []byte, mediaType string) (ociregistry.Descriptor, error) {
    38  	if mediaType == "" {
    39  		return ociregistry.Descriptor{}, fmt.Errorf("PushManifest called with empty mediaType")
    40  	}
    41  	desc := ociregistry.Descriptor{
    42  		Digest:    digest.FromBytes(contents),
    43  		Size:      int64(len(contents)),
    44  		MediaType: mediaType,
    45  	}
    46  
    47  	rreq := &ocirequest.Request{
    48  		Kind:   ocirequest.ReqManifestPut,
    49  		Repo:   repo,
    50  		Tag:    tag,
    51  		Digest: string(desc.Digest),
    52  	}
    53  	req, err := newRequest(ctx, rreq, bytes.NewReader(contents))
    54  	if err != nil {
    55  		return ociregistry.Descriptor{}, err
    56  	}
    57  	req.Header.Set("Content-Type", mediaType)
    58  	req.ContentLength = desc.Size
    59  	resp, err := c.do(req, http.StatusCreated)
    60  	if err != nil {
    61  		return ociregistry.Descriptor{}, err
    62  	}
    63  	resp.Body.Close()
    64  	return desc, nil
    65  }
    66  
    67  func (c *client) MountBlob(ctx context.Context, fromRepo, toRepo string, dig ociregistry.Digest) (ociregistry.Descriptor, error) {
    68  	rreq := &ocirequest.Request{
    69  		Kind:     ocirequest.ReqBlobMount,
    70  		Repo:     toRepo,
    71  		FromRepo: fromRepo,
    72  		Digest:   string(dig),
    73  	}
    74  	resp, err := c.doRequest(ctx, rreq, http.StatusCreated, http.StatusAccepted)
    75  	if err != nil {
    76  		return ociregistry.Descriptor{}, err
    77  	}
    78  	resp.Body.Close()
    79  	if resp.StatusCode == http.StatusAccepted {
    80  		// Mount isn't supported and technically the upload session has begun,
    81  		// but we aren't in a great position to be able to continue it, so let's just
    82  		// return Unsupported.
    83  		return ociregistry.Descriptor{}, fmt.Errorf("registry does not support mounts: %w", ociregistry.ErrUnsupported)
    84  	}
    85  	// TODO: is it OK to omit the size from the returned descriptor here?
    86  	return descriptorFromResponse(resp, dig, requireDigest)
    87  }
    88  
    89  func (c *client) PushBlob(ctx context.Context, repo string, desc ociregistry.Descriptor, r io.Reader) (_ ociregistry.Descriptor, _err error) {
    90  	// TODO use the single-post blob-upload method (ReqBlobUploadBlob)
    91  	// See:
    92  	//	https://github.com/distribution/distribution/issues/4065
    93  	//	https://github.com/golang/go/issues/63152
    94  	rreq := &ocirequest.Request{
    95  		Kind: ocirequest.ReqBlobStartUpload,
    96  		Repo: repo,
    97  	}
    98  	req, err := newRequest(ctx, rreq, nil)
    99  	if err != nil {
   100  		return ociregistry.Descriptor{}, err
   101  	}
   102  	resp, err := c.do(req, http.StatusAccepted)
   103  	if err != nil {
   104  		return ociregistry.Descriptor{}, err
   105  	}
   106  	resp.Body.Close()
   107  	location, err := locationFromResponse(resp)
   108  	if err != nil {
   109  		return ociregistry.Descriptor{}, err
   110  	}
   111  
   112  	// We've got the upload location. Now PUT the content.
   113  
   114  	ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{
   115  		RequiredScope: scopeForRequest(rreq),
   116  	})
   117  	// Note: we can't use ocirequest.Request here because that's
   118  	// specific to the ociserver implementation in this case.
   119  	req, err = http.NewRequestWithContext(ctx, "PUT", "", r)
   120  	if err != nil {
   121  		return ociregistry.Descriptor{}, err
   122  	}
   123  	req.URL = urlWithDigest(location, string(desc.Digest))
   124  	req.ContentLength = desc.Size
   125  	req.Header.Set("Content-Type", "application/octet-stream")
   126  	// TODO: per the spec, the content-range header here is unnecessary.
   127  	req.Header.Set("Content-Range", ocirequest.RangeString(0, desc.Size))
   128  	resp, err = c.do(req, http.StatusCreated)
   129  	if err != nil {
   130  		return ociregistry.Descriptor{}, err
   131  	}
   132  	defer closeOnError(&_err, resp.Body)
   133  	resp.Body.Close()
   134  	return desc, nil
   135  }
   136  
   137  // TODO is this a reasonable default? We have to
   138  // weigh up in-memory cost vs round-trip overhead.
   139  // TODO: make this default configurable.
   140  const defaultChunkSize = 64 * 1024
   141  
   142  func (c *client) PushBlobChunked(ctx context.Context, repo string, chunkSize int) (ociregistry.BlobWriter, error) {
   143  	if chunkSize <= 0 {
   144  		chunkSize = defaultChunkSize
   145  	}
   146  	resp, err := c.doRequest(ctx, &ocirequest.Request{
   147  		Kind: ocirequest.ReqBlobStartUpload,
   148  		Repo: repo,
   149  	}, http.StatusAccepted)
   150  	if err != nil {
   151  		return nil, err
   152  	}
   153  	resp.Body.Close()
   154  	location, err := locationFromResponse(resp)
   155  	if err != nil {
   156  		return nil, err
   157  	}
   158  	ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{
   159  		RequiredScope: ociauth.NewScope(ociauth.ResourceScope{
   160  			ResourceType: "repository",
   161  			Resource:     repo,
   162  			Action:       "push",
   163  		}),
   164  	})
   165  	return &blobWriter{
   166  		ctx:       ctx,
   167  		client:    c,
   168  		chunkSize: chunkSizeFromResponse(resp, chunkSize),
   169  		chunk:     make([]byte, 0, chunkSize),
   170  		location:  location,
   171  	}, nil
   172  }
   173  
   174  func (c *client) PushBlobChunkedResume(ctx context.Context, repo string, id string, offset int64, chunkSize int) (ociregistry.BlobWriter, error) {
   175  	if id == "" {
   176  		return nil, fmt.Errorf("id must be non-empty to resume a chunked upload")
   177  	}
   178  	if chunkSize <= 0 {
   179  		chunkSize = defaultChunkSize
   180  	}
   181  	var location *url.URL
   182  	switch {
   183  	case offset == -1:
   184  		// Try to find what offset we're meant to be writing at
   185  		// by doing a GET to the location.
   186  		// TODO does resuming an upload require push or pull scope or both?
   187  		ctx := ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{
   188  			RequiredScope: ociauth.NewScope(ociauth.ResourceScope{
   189  				ResourceType: "repository",
   190  				Resource:     repo,
   191  				Action:       "push",
   192  			}, ociauth.ResourceScope{
   193  				ResourceType: "repository",
   194  				Resource:     repo,
   195  				Action:       "pull",
   196  			}),
   197  		})
   198  		req, err := http.NewRequestWithContext(ctx, "GET", id, nil)
   199  		if err != nil {
   200  			return nil, err
   201  		}
   202  		resp, err := c.do(req, http.StatusNoContent)
   203  		if err != nil {
   204  			return nil, fmt.Errorf("cannot recover chunk offset: %v", err)
   205  		}
   206  		location, err = locationFromResponse(resp)
   207  		if err != nil {
   208  			return nil, fmt.Errorf("cannot get location from response: %v", err)
   209  		}
   210  		rangeStr := resp.Header.Get("Range")
   211  		p0, p1, ok := ocirequest.ParseRange(rangeStr)
   212  		if !ok {
   213  			return nil, fmt.Errorf("invalid range %q in response", rangeStr)
   214  		}
   215  		if p0 != 0 {
   216  			return nil, fmt.Errorf("range %q does not start with 0", rangeStr)
   217  		}
   218  		chunkSize = chunkSizeFromResponse(resp, chunkSize)
   219  		offset = p1
   220  	case offset < 0:
   221  		return nil, fmt.Errorf("invalid offset; must be -1 or non-negative")
   222  	default:
   223  		var err error
   224  		location, err = url.Parse(id) // Note that this mirrors [BlobWriter.ID].
   225  		if err != nil {
   226  			return nil, fmt.Errorf("provided ID is not a valid location URL")
   227  		}
   228  		if !strings.HasPrefix(location.Path, "/") {
   229  			// Our BlobWriter.ID method always returns a fully
   230  			// qualified absolute URL, so this must be a mistake
   231  			// on the part of the caller.
   232  			// We allow a relative URL even though we don't
   233  			// ever return one to make things a bit easier for tests.
   234  			return nil, fmt.Errorf("provided upload ID %q has unexpected relative URL path", id)
   235  		}
   236  	}
   237  	ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{
   238  		RequiredScope: ociauth.NewScope(ociauth.ResourceScope{
   239  			ResourceType: "repository",
   240  			Resource:     repo,
   241  			Action:       "push",
   242  		}),
   243  	})
   244  	return &blobWriter{
   245  		ctx:       ctx,
   246  		client:    c,
   247  		chunkSize: chunkSize,
   248  		size:      offset,
   249  		flushed:   offset,
   250  		location:  location,
   251  	}, nil
   252  }
   253  
   254  type blobWriter struct {
   255  	client    *client
   256  	chunkSize int
   257  	ctx       context.Context
   258  
   259  	// mu guards the fields below it.
   260  	mu       sync.Mutex
   261  	closed   bool
   262  	chunk    []byte
   263  	closeErr error
   264  
   265  	// size holds the size of the entire upload as seen from the
   266  	// client perspective. Each call to Write increases this immediately.
   267  	size int64
   268  
   269  	// flushed holds the size of the upload as flushed to the server.
   270  	// Each successfully flushed chunk increases this.
   271  	flushed  int64
   272  	location *url.URL
   273  }
   274  
   275  func (w *blobWriter) Write(buf []byte) (int, error) {
   276  	w.mu.Lock()
   277  	defer w.mu.Unlock()
   278  
   279  	// We use > rather than >= here so that using a chunk size of 100
   280  	// and writing 100 bytes does not actually flush, which would result in a PATCH
   281  	// then followed by an empty-bodied PUT with the call to Commit.
   282  	// Instead, we want the writes to not flush at all, and Commit to PUT the entire chunk.
   283  	if len(w.chunk)+len(buf) > w.chunkSize {
   284  		if err := w.flush(buf, ""); err != nil {
   285  			return 0, err
   286  		}
   287  	} else {
   288  		if w.chunk == nil {
   289  			w.chunk = make([]byte, 0, w.chunkSize)
   290  		}
   291  		w.chunk = append(w.chunk, buf...)
   292  	}
   293  	w.size += int64(len(buf))
   294  	return len(buf), nil
   295  }
   296  
   297  // flush flushes any outstanding upload data to the server.
   298  // If commitDigest is non-empty, this is the final segment of data in the blob:
   299  // the blob is being committed and the digest should hold the digest of the entire blob content.
   300  func (w *blobWriter) flush(buf []byte, commitDigest ociregistry.Digest) error {
   301  	if commitDigest == "" && len(buf)+len(w.chunk) == 0 {
   302  		return nil
   303  	}
   304  	// Start a new PATCH request to send the currently outstanding data.
   305  	method := "PATCH"
   306  	expect := http.StatusAccepted
   307  	reqURL := w.location
   308  	if commitDigest != "" {
   309  		// This is the final piece of data, so send it as the final PUT request
   310  		// (committing the whole blob) which avoids an extra round trip.
   311  		method = "PUT"
   312  		expect = http.StatusCreated
   313  		reqURL = urlWithDigest(reqURL, string(commitDigest))
   314  	}
   315  	req, err := http.NewRequestWithContext(w.ctx, method, "", concatBody(w.chunk, buf))
   316  	if err != nil {
   317  		return fmt.Errorf("cannot make PATCH request: %v", err)
   318  	}
   319  	req.URL = reqURL
   320  	req.ContentLength = int64(len(w.chunk) + len(buf))
   321  	// TODO: per the spec, the content-range header here is unnecessary
   322  	// if we are doing a final PUT without a body.
   323  	req.Header.Set("Content-Range", ocirequest.RangeString(w.flushed, w.flushed+req.ContentLength))
   324  	resp, err := w.client.do(req, expect)
   325  	if err != nil {
   326  		return err
   327  	}
   328  	resp.Body.Close()
   329  	location, err := locationFromResponse(resp)
   330  	if err != nil {
   331  		return fmt.Errorf("bad Location in response: %v", err)
   332  	}
   333  	// TODO is there something we could be doing with the Range header in the response?
   334  	w.location = location
   335  	w.flushed += req.ContentLength
   336  	w.chunk = w.chunk[:0]
   337  	return nil
   338  }
   339  
   340  func concatBody(b1, b2 []byte) io.Reader {
   341  	if len(b1)+len(b2) == 0 {
   342  		return nil // note that net/http treats a nil request body differently
   343  	}
   344  	if len(b1) == 0 {
   345  		return bytes.NewReader(b2)
   346  	}
   347  	if len(b2) == 0 {
   348  		return bytes.NewReader(b1)
   349  	}
   350  	return io.MultiReader(
   351  		bytes.NewReader(b1),
   352  		bytes.NewReader(b2),
   353  	)
   354  }
   355  
   356  func (w *blobWriter) Close() error {
   357  	w.mu.Lock()
   358  	defer w.mu.Unlock()
   359  	if w.closed {
   360  		return w.closeErr
   361  	}
   362  	err := w.flush(nil, "")
   363  	w.closed = true
   364  	w.closeErr = err
   365  	return err
   366  }
   367  
   368  func (w *blobWriter) Size() int64 {
   369  	w.mu.Lock()
   370  	defer w.mu.Unlock()
   371  	return w.size
   372  }
   373  
   374  func (w *blobWriter) ChunkSize() int {
   375  	return w.chunkSize
   376  }
   377  
   378  func (w *blobWriter) ID() string {
   379  	w.mu.Lock()
   380  	defer w.mu.Unlock()
   381  	return w.location.String()
   382  }
   383  
   384  func (w *blobWriter) Commit(digest ociregistry.Digest) (ociregistry.Descriptor, error) {
   385  	if digest == "" {
   386  		return ociregistry.Descriptor{}, fmt.Errorf("cannot commit with an empty digest")
   387  	}
   388  	w.mu.Lock()
   389  	defer w.mu.Unlock()
   390  	if err := w.flush(nil, digest); err != nil {
   391  		return ociregistry.Descriptor{}, fmt.Errorf("cannot flush data before commit: %w", err)
   392  	}
   393  	return ociregistry.Descriptor{
   394  		MediaType: "application/octet-stream",
   395  		Size:      w.size,
   396  		Digest:    digest,
   397  	}, nil
   398  }
   399  
   400  func (w *blobWriter) Cancel() error {
   401  	return nil
   402  }
   403  
   404  // urlWithDigest returns u with the digest query parameter set, taking care not
   405  // to disrupt the initial URL (thus avoiding the charge of "manually
   406  // assembing the location; see [here].
   407  //
   408  // [here]: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put
   409  func urlWithDigest(u0 *url.URL, digest string) *url.URL {
   410  	u := *u0
   411  	digest = url.QueryEscape(digest)
   412  	switch {
   413  	case u.ForceQuery:
   414  		// The URL already ended in a "?" with no actual query parameters.
   415  		u.RawQuery = "digest=" + digest
   416  		u.ForceQuery = false
   417  	case u.RawQuery != "":
   418  		// There's already a query parameter present.
   419  		u.RawQuery += "&digest=" + digest
   420  	default:
   421  		u.RawQuery = "digest=" + digest
   422  	}
   423  	return &u
   424  }
   425  
   426  // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
   427  func chunkSizeFromResponse(resp *http.Response, chunkSize int) int {
   428  	minChunkSize, err := strconv.Atoi(resp.Header.Get("OCI-Chunk-Min-Length"))
   429  	if err == nil && minChunkSize > chunkSize {
   430  		return minChunkSize
   431  	}
   432  	return chunkSize
   433  }