...

Source file src/cuelabs.dev/go/oci/ociregistry/ociserver/writer.go

Documentation: cuelabs.dev/go/oci/ociregistry/ociserver

     1  // Copyright 2018 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  package ociserver
    16  
    17  import (
    18  	"context"
    19  	"encoding/json"
    20  	"fmt"
    21  	"io"
    22  	"net/http"
    23  	"strconv"
    24  
    25  	"github.com/opencontainers/go-digest"
    26  	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
    27  
    28  	"cuelabs.dev/go/oci/ociregistry"
    29  	"cuelabs.dev/go/oci/ociregistry/internal/ocirequest"
    30  )
    31  
    32  func (r *registry) handleBlobUploadBlob(ctx context.Context, resp http.ResponseWriter, req *http.Request, rreq *ocirequest.Request) error {
    33  	if r.opts.DisableSinglePostUpload {
    34  		return r.handleBlobStartUpload(ctx, resp, req, rreq)
    35  	}
    36  	// TODO check that Content-Type is application/octet-stream?
    37  	mediaType := mediaTypeOctetStream
    38  
    39  	desc, err := r.backend.PushBlob(req.Context(), rreq.Repo, ociregistry.Descriptor{
    40  		MediaType: mediaType,
    41  		Size:      req.ContentLength,
    42  		Digest:    ociregistry.Digest(rreq.Digest),
    43  	}, req.Body)
    44  	if err != nil {
    45  		return err
    46  	}
    47  	if err := r.setLocationHeader(resp, false, desc, "/v2/"+rreq.Repo+"/blobs/"+string(desc.Digest)); err != nil {
    48  		return err
    49  	}
    50  	resp.WriteHeader(http.StatusCreated)
    51  	return nil
    52  }
    53  
    54  func (r *registry) handleBlobStartUpload(ctx context.Context, resp http.ResponseWriter, req *http.Request, rreq *ocirequest.Request) error {
    55  	// Start a chunked upload. When r.backend is ociclient, this should
    56  	// just result in a single POST request that starts the upload.
    57  	w, err := r.backend.PushBlobChunked(ctx, rreq.Repo, 0)
    58  	if err != nil {
    59  		return err
    60  	}
    61  	defer w.Close()
    62  
    63  	resp.Header().Set("Location", r.locationForUploadID(rreq.Repo, w.ID()))
    64  	resp.Header().Set("Range", "0-0")
    65  	// TODO: reject chunks which don't follow this minimum length.
    66  	// If any reasonable clients are broken by this, we can always reconsider,
    67  	// perhaps by making the strictness on chunk sizes opt-in.
    68  	resp.Header().Set("OCI-Chunk-Min-Length", strconv.Itoa(w.ChunkSize()))
    69  	resp.WriteHeader(http.StatusAccepted)
    70  	return nil
    71  }
    72  
    73  func (r *registry) handleBlobUploadInfo(ctx context.Context, resp http.ResponseWriter, req *http.Request, rreq *ocirequest.Request) error {
    74  	// Resume the upload without actually writing to it, passing -1 for the offset
    75  	// to cause the backend to retrieve the associated upload information.
    76  	// When r.backend is ociclient, this should result in a single GET request
    77  	// to retrieve upload info.
    78  	w, err := r.backend.PushBlobChunkedResume(ctx, rreq.Repo, rreq.UploadID, -1, 0)
    79  	if err != nil {
    80  		return err
    81  	}
    82  	defer w.Close()
    83  	resp.Header().Set("Location", r.locationForUploadID(rreq.Repo, w.ID()))
    84  	resp.Header().Set("Range", ocirequest.RangeString(0, w.Size()))
    85  	resp.WriteHeader(http.StatusNoContent)
    86  	return nil
    87  }
    88  
    89  func (r *registry) handleBlobUploadChunk(ctx context.Context, resp http.ResponseWriter, req *http.Request, rreq *ocirequest.Request) error {
    90  	// Note that the spec requires chunked upload PATCH requests to include Content-Range,
    91  	// but the conformance tests do not actually follow that as of the time of writing.
    92  	// Allow the missing header to result in start=0, meaning we assume it's the first chunk.
    93  	start, end, err := chunkRange(req)
    94  	if err != nil {
    95  		return err
    96  	}
    97  
    98  	w, err := r.backend.PushBlobChunkedResume(ctx, rreq.Repo, rreq.UploadID, start, int(end-start))
    99  	if err != nil {
   100  		return err
   101  	}
   102  	if _, err := io.Copy(w, req.Body); err != nil {
   103  		w.Close()
   104  		return fmt.Errorf("cannot copy blob data: %w", err)
   105  	}
   106  	if err := w.Close(); err != nil {
   107  		return fmt.Errorf("cannot close BlobWriter: %w", err)
   108  	}
   109  	resp.Header().Set("Location", r.locationForUploadID(rreq.Repo, w.ID()))
   110  	resp.Header().Set("Range", ocirequest.RangeString(0, w.Size()))
   111  	resp.WriteHeader(http.StatusAccepted)
   112  	return nil
   113  }
   114  
   115  func (r *registry) handleBlobCompleteUpload(ctx context.Context, resp http.ResponseWriter, req *http.Request, rreq *ocirequest.Request) error {
   116  	// We are handling a PUT as part of one of:
   117  	//
   118  	// 1) An entire blob via POST-then-PUT.
   119  	// 2) The last chunk of a chunked upload as part of the closing PUT, with a valid Content-Range.
   120  	// 3) Closing a finished chunked upload with an empty-bodied PUT.
   121  	//
   122  	// We can't actually tell these apart upfront;
   123  	// for example, 3 can have an octet-stream content type even though it has no body,
   124  	// meaning that it looks exactly like 1, as seen in the conformance tests.
   125  	// For that reason, we simply forward the range start as the offset in case 2,
   126  	// while using an offset of 0 in cases 1 and 3 without a range, to avoid a GET in ociclient.
   127  	//
   128  	// Note that we don't check "ok" here, letting "start" default to 0 due to the above.
   129  	start, end, err := chunkRange(req)
   130  	if err != nil {
   131  		return err
   132  	}
   133  
   134  	w, err := r.backend.PushBlobChunkedResume(ctx, rreq.Repo, rreq.UploadID, start, int(end-start))
   135  	if err != nil {
   136  		return err
   137  	}
   138  	defer w.Close()
   139  
   140  	if _, err := io.Copy(w, req.Body); err != nil {
   141  		return fmt.Errorf("failed to copy data to %T: %v", w, err)
   142  	}
   143  	desc, err := w.Commit(ociregistry.Digest(rreq.Digest))
   144  	if err != nil {
   145  		return err
   146  	}
   147  	if err := r.setLocationHeader(resp, false, desc, "/v2/"+rreq.Repo+"/blobs/"+string(desc.Digest)); err != nil {
   148  		return err
   149  	}
   150  	resp.WriteHeader(http.StatusCreated)
   151  	return nil
   152  }
   153  
   154  func (r *registry) handleBlobMount(ctx context.Context, resp http.ResponseWriter, req *http.Request, rreq *ocirequest.Request) error {
   155  	desc, err := r.backend.MountBlob(ctx, rreq.FromRepo, rreq.Repo, ociregistry.Digest(rreq.Digest))
   156  	if err != nil {
   157  		return err
   158  	}
   159  	if err := r.setLocationHeader(resp, true, desc, "/v2/"+rreq.Repo+"/blobs/"+rreq.Digest); err != nil {
   160  		return err
   161  	}
   162  	resp.WriteHeader(http.StatusCreated)
   163  	return nil
   164  }
   165  
   166  func (r *registry) handleManifestPut(ctx context.Context, resp http.ResponseWriter, req *http.Request, rreq *ocirequest.Request) error {
   167  	mediaType := req.Header.Get("Content-Type")
   168  	if mediaType == "" {
   169  		mediaType = mediaTypeOctetStream
   170  	}
   171  	// TODO check that the media type is valid?
   172  	// TODO size limit
   173  	data, err := io.ReadAll(req.Body)
   174  	if err != nil {
   175  		return fmt.Errorf("cannot read content: %v", err)
   176  	}
   177  	dig := digest.FromBytes(data)
   178  	var tag string
   179  	if rreq.Tag != "" {
   180  		tag = rreq.Tag
   181  	} else {
   182  		if ociregistry.Digest(rreq.Digest) != dig {
   183  			return ociregistry.ErrDigestInvalid
   184  		}
   185  	}
   186  	subjectDesc, err := subjectFromManifest(req.Header.Get("Content-Type"), data)
   187  	if err != nil {
   188  		return fmt.Errorf("invalid manifest JSON: %v", err)
   189  	}
   190  	desc, err := r.backend.PushManifest(ctx, rreq.Repo, tag, data, mediaType)
   191  	if err != nil {
   192  		return err
   193  	}
   194  	if err := r.setLocationHeader(resp, false, desc, "/v2/"+rreq.Repo+"/manifests/"+string(desc.Digest)); err != nil {
   195  		return err
   196  	}
   197  	if subjectDesc != nil {
   198  		resp.Header().Set("OCI-Subject", string(subjectDesc.Digest))
   199  	}
   200  	// TODO OCI-Subject header?
   201  	resp.WriteHeader(http.StatusCreated)
   202  	return nil
   203  }
   204  
   205  func subjectFromManifest(contentType string, data []byte) (*ociregistry.Descriptor, error) {
   206  	switch contentType {
   207  	case ocispec.MediaTypeImageManifest,
   208  		ocispec.MediaTypeImageIndex:
   209  		break
   210  		// TODO other manifest media types.
   211  	default:
   212  		return nil, nil
   213  	}
   214  	var m struct {
   215  		Subject *ociregistry.Descriptor `json:"subject"`
   216  	}
   217  	if err := json.Unmarshal(data, &m); err != nil {
   218  		return nil, err
   219  	}
   220  	return m.Subject, nil
   221  }
   222  
   223  func (r *registry) locationForUploadID(repo string, uploadID string) string {
   224  	_, loc := (&ocirequest.Request{
   225  		Kind:     ocirequest.ReqBlobUploadInfo,
   226  		Repo:     repo,
   227  		UploadID: uploadID,
   228  	}).MustConstruct()
   229  	return loc
   230  }
   231  
   232  func chunkRange(req *http.Request) (start, end int64, _ error) {
   233  	var rangeOK bool
   234  	if s := req.Header.Get("Content-Range"); s != "" {
   235  		start, end, rangeOK = ocirequest.ParseRange(s)
   236  		if !rangeOK {
   237  			return 0, 0, badAPIUseError("we don't understand your Content-Range")
   238  		}
   239  	}
   240  
   241  	if rangeOK && req.ContentLength >= 0 {
   242  		rangeLength := end - start
   243  		if rangeLength != req.ContentLength {
   244  			return 0, 0, badAPIUseError("Content-Range implies a length of %d but Content-Length is %d", rangeLength, req.ContentLength)
   245  		}
   246  	}
   247  
   248  	// The registry here is stateless, so it doesn't remember what minimum chunk size
   249  	// the backend registry suggested that we should use.
   250  	// We rely on the HTTP client to remember that minimum and use it,
   251  	// which would mean that each PATCH chunk before the last should be at least as large.
   252  	// Extract that size from either Content-Range or Content-Length;
   253  	// if neither is set, we fall back to 0, letting the backend assume a default.
   254  	if !rangeOK && req.ContentLength >= 0 {
   255  		end = req.ContentLength
   256  	}
   257  	return start, end, nil
   258  }
   259  

View as plain text