cuelabs.dev/go/oci/ociregistry@v0.0.0-20240906074133-82eb438dd565/ociserver/writer.go (about) 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 }